From 8633c26131946ac9cbbc5a35140c93f6db245b77 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 10:05:42 -0700 Subject: [PATCH 01/97] chore: start v2.0 development cycle --- src/pullbox/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pullbox/__init__.py b/src/pullbox/__init__.py index dd412321..03c0962d 100644 --- a/src/pullbox/__init__.py +++ b/src/pullbox/__init__.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime -__version__ = "1.3.1-dev" +__version__ = "2.0.0-dev" # Set once at process start; used by System > About for uptime calculation. STARTED_AT: datetime = datetime.now(UTC) From ae077b660627812734f9f95a3cdc283bd728ec9e Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 11:09:13 -0700 Subject: [PATCH 02/97] feat: establish metadata provider identity foundations --- scripts/benchmark_mylar3_import.py | 22 +- src/pullbox/core/metadata_identity.py | 148 ++++++++++++ tests/unit/test_metadata_identity.py | 214 ++++++++++++++++++ .../test_performance_benchmark_scripts.py | 19 ++ 4 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 src/pullbox/core/metadata_identity.py create mode 100644 tests/unit/test_metadata_identity.py diff --git a/scripts/benchmark_mylar3_import.py b/scripts/benchmark_mylar3_import.py index 96c7cf1e..955e9ee5 100644 --- a/scripts/benchmark_mylar3_import.py +++ b/scripts/benchmark_mylar3_import.py @@ -36,11 +36,12 @@ ImportedFile, ImportedFileStatus, ImportedSeries, - ImportJob, ImportJobStatus, ImportSourceType, ) +from pullbox.models.library import LibraryRoot from pullbox.performance.baseline import current_process_peak_rss_bytes +from pullbox.schemas.import_job import ImportJobCreate from pullbox.services.import_service import ImportService @@ -122,12 +123,21 @@ def counting_read_zip( try: async with session_factory() as session: - job = ImportJob( - source_path=str(fixture.db_path), - source_type=ImportSourceType.MYLAR3, - status=ImportJobStatus.PENDING, + destination = workspace / "managed-library" + destination.mkdir() + root = LibraryRoot(name="Benchmark destination", path=str(destination), enabled=True) + session.add(root) + await session.flush() + # Use Step 1's revalidated identity mapping, not a synthetic scan bypass. + job = await service.create_job( + session, + ImportJobCreate( + source_path=str(fixture.db_path), + source_type=ImportSourceType.MYLAR3, + target_library_root_id=root.id, + mylar3_path_map_confirmed=True, + ), ) - session.add(job) await session.commit() started_at = time.monotonic() diff --git a/src/pullbox/core/metadata_identity.py b/src/pullbox/core/metadata_identity.py new file mode 100644 index 00000000..c90f4b62 --- /dev/null +++ b/src/pullbox/core/metadata_identity.py @@ -0,0 +1,148 @@ +"""Provider identity primitives; no provider execution or persistence policy.""" + +from __future__ import annotations + +import enum +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class IdentityNamespace(enum.StrEnum): + """Provider namespaces, independent of transport and configuration.""" + + COMICVINE = "comicvine" + METRON = "metron" + GCD = "gcd" + LOCG = "locg" + + +class MetadataSource(enum.StrEnum): + """Known source names, not a registry of enabled providers.""" + + COMICVINE_LOCAL = "comicvine_local" + COMICVINE_API = "comicvine_api" + METRON_API = "metron_api" + GCD_LOCAL = "gcd_local" + GCD_API_V2 = "gcd_api_v2" + + @property + def identity_namespace(self) -> IdentityNamespace: + return _SOURCE_NAMESPACES[self] + + +_SOURCE_NAMESPACES = { + MetadataSource.COMICVINE_LOCAL: IdentityNamespace.COMICVINE, + MetadataSource.COMICVINE_API: IdentityNamespace.COMICVINE, + MetadataSource.METRON_API: IdentityNamespace.METRON, + MetadataSource.GCD_LOCAL: IdentityNamespace.GCD, + MetadataSource.GCD_API_V2: IdentityNamespace.GCD, +} + + +class MetadataEntityKind(enum.StrEnum): + """Kinds of canonical metadata entities, not release variants.""" + + SERIES = "series" + ISSUE = "issue" + STORY_ARC = "story_arc" + + +class IdentityEvidenceKind(enum.StrEnum): + """Origin of an identity assertion; origin alone does not establish trust.""" + + LEGACY_BACKFILL = "legacy_backfill" + MYLAR_DATABASE = "mylar_database" + SERIES_JSON = "series_json" + COMICINFO_XML = "comicinfo_xml" + METRONINFO_XML = "metroninfo_xml" + PROVIDER_RESULT = "provider_result" + PROVIDER_CROSSWALK = "provider_crosswalk" + USER_SELECTION = "user_selection" + MIGRATION = "migration" + + +@dataclass(frozen=True) +class ExternalIdentityRef: + """Canonical numeric provider key, not an issue designation or release ID. + + These keys do not establish cross-provider sameness or verification. Adapters + must validate provider URLs and extract their IDs before constructing a key. + LOCG release/variant IDs belong to discovery references, not canonical issues. + """ + + namespace: IdentityNamespace + entity_kind: MetadataEntityKind + external_id: str + + def __post_init__(self) -> None: + if not isinstance(self.namespace, IdentityNamespace): + raise ValueError("Unknown identity namespace") + if not isinstance(self.entity_kind, MetadataEntityKind): + raise ValueError("Unknown metadata entity kind") + if not isinstance(self.external_id, str) or len(self.external_id) > 255: + raise ValueError("Invalid external ID: expected bounded decimal text") + value = self.external_id.strip(" ") + if not value.isascii() or not value.isdecimal() or not value.lstrip("0"): + raise ValueError("Invalid external ID: expected a positive decimal ID") + object.__setattr__(self, "external_id", value.lstrip("0")) + + +@dataclass(frozen=True) +class ExactIdentityEvidence: + """An exact-ID assertion, not proof of trust or authorization to attach it.""" + + identity: ExternalIdentityRef + evidence_kind: IdentityEvidenceKind + source_instance: MetadataSource | None = None + + def __post_init__(self) -> None: + if not isinstance(self.evidence_kind, IdentityEvidenceKind): + raise ValueError("Unknown identity evidence kind") + if self.source_instance is not None and not isinstance( + self.source_instance, MetadataSource + ): + raise ValueError("Unknown metadata source instance") + + +@dataclass(frozen=True) +class ExactIdentityConflict: + """Competing IDs for one proposed target, with their evidence retained.""" + + namespace: IdentityNamespace + entity_kind: MetadataEntityKind + evidence: tuple[ExactIdentityEvidence, ...] + + +def find_exact_identity_conflicts( + evidence: Iterable[ExactIdentityEvidence], +) -> tuple[ExactIdentityConflict, ...]: + """Find same-provider disagreement for one target; never select a winner. + + Call once per proposed local target, not across a scan or search result list. + No conflicts does not mean the assertions are verified, linked, unowned, or + safe to attach. Persistence and crosswalk resolution enforce those separately. + """ + grouped: dict[tuple[IdentityNamespace, MetadataEntityKind], set[ExactIdentityEvidence]] = ( + defaultdict(set) + ) + for observation in evidence: + identity = observation.identity + grouped[(identity.namespace, identity.entity_kind)].add(observation) + + return tuple( + ExactIdentityConflict(namespace, kind, tuple(sorted(observations, key=_evidence_sort_key))) + for (namespace, kind), observations in sorted(grouped.items()) + if len({item.identity.external_id for item in observations}) > 1 + ) + + +def _evidence_sort_key(evidence: ExactIdentityEvidence) -> tuple[str, str, str]: + return ( + evidence.identity.external_id, + evidence.evidence_kind.value, + evidence.source_instance.value if evidence.source_instance is not None else "", + ) diff --git a/tests/unit/test_metadata_identity.py b/tests/unit/test_metadata_identity.py new file mode 100644 index 00000000..edb23c9b --- /dev/null +++ b/tests/unit/test_metadata_identity.py @@ -0,0 +1,214 @@ +"""Provider keys and source provenance must not silently settle identity conflicts.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from itertools import permutations +from typing import cast + +import pytest + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, + find_exact_identity_conflicts, +) + + +def _identity( + external_id: str, + namespace: IdentityNamespace = IdentityNamespace.COMICVINE, + kind: MetadataEntityKind = MetadataEntityKind.SERIES, +) -> ExternalIdentityRef: + return ExternalIdentityRef(namespace, kind, external_id) + + +@pytest.mark.parametrize( + ("source", "namespace"), + [ + (MetadataSource.COMICVINE_LOCAL, IdentityNamespace.COMICVINE), + (MetadataSource.COMICVINE_API, IdentityNamespace.COMICVINE), + (MetadataSource.METRON_API, IdentityNamespace.METRON), + (MetadataSource.GCD_LOCAL, IdentityNamespace.GCD), + (MetadataSource.GCD_API_V2, IdentityNamespace.GCD), + ], +) +def test_local_and_api_sources_share_one_namespace(source, namespace) -> None: + assert source.identity_namespace is namespace + + +@pytest.mark.parametrize("namespace", list(IdentityNamespace)) +@pytest.mark.parametrize("raw", ["42", "00042", " 42 "]) +def test_numeric_provider_keys_are_canonical_decimal_text(namespace, raw) -> None: + identity = _identity(raw, namespace) + assert identity.external_id == "42" + assert identity == _identity("42", namespace) + assert hash(identity) == hash(_identity("42", namespace)) + + +@pytest.mark.parametrize( + "raw", + [ + "", + " ", + "0", + "000", + "-1", + "+42", + "1.5", + "1e3", + "13A", + "50-X", + "42\n", + "4\t2", + "\x0042", + "\uff14\uff12", + "4050-42", + "https://example.test/42", + "1" * 256, + ], +) +def test_invalid_provider_keys_are_not_coerced_into_identities(raw: str) -> None: + with pytest.raises(ValueError, match="external ID"): + _identity(raw) + + +@pytest.mark.parametrize("raw", [None, True, 42, 42.0, {}, []]) +def test_non_text_input_requires_explicit_adapter_normalization(raw: object) -> None: + with pytest.raises(ValueError, match="external ID"): + _identity(cast("str", raw)) + + +def test_identity_scope_includes_namespace_and_entity_kind() -> None: + identities = { + _identity("42", namespace, kind) + for namespace in IdentityNamespace + for kind in MetadataEntityKind + } + assert len(identities) == 12 + + +def test_identity_keys_do_not_allow_unknown_namespaces_or_entity_kinds() -> None: + with pytest.raises(ValueError, match="namespace"): + _identity("42", cast("IdentityNamespace", "gcd_api_v2")) + with pytest.raises(ValueError, match="entity kind"): + _identity("42", kind=cast("MetadataEntityKind", "release_variant")) + + +def test_identity_is_immutable() -> None: + identity = _identity("42") + with pytest.raises(FrozenInstanceError): + identity.external_id = "43" # type: ignore[misc] + + +def test_locg_identity_requires_no_executable_provider() -> None: + identity = _identity("42", IdentityNamespace.LOCG) + evidence = ExactIdentityEvidence(identity, IdentityEvidenceKind.USER_SELECTION) + assert evidence.source_instance is None + assert identity.namespace is IdentityNamespace.LOCG + assert "locg" not in {source.value for source in MetadataSource} + assert find_exact_identity_conflicts([evidence]) == () + + +def test_transport_provenance_is_retained_without_splitting_identity() -> None: + identity = _identity("42", IdentityNamespace.GCD) + local = ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.GCD_LOCAL + ) + api = ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.GCD_API_V2 + ) + assert local != api + assert local.identity == api.identity + assert local.source_instance is MetadataSource.GCD_LOCAL + assert api.source_instance is MetadataSource.GCD_API_V2 + assert find_exact_identity_conflicts([local, api]) == () + + +def test_crosswalk_origin_can_differ_from_asserted_identity_namespace() -> None: + evidence = ExactIdentityEvidence( + _identity("42"), IdentityEvidenceKind.PROVIDER_CROSSWALK, MetadataSource.METRON_API + ) + assert evidence.identity.namespace is IdentityNamespace.COMICVINE + assert evidence.source_instance is MetadataSource.METRON_API + + +def test_evidence_rejects_unknown_origin_and_source() -> None: + with pytest.raises(ValueError, match="evidence kind"): + ExactIdentityEvidence(_identity("42"), cast("IdentityEvidenceKind", "title_guess")) + with pytest.raises(ValueError, match="source instance"): + ExactIdentityEvidence( + _identity("42"), IdentityEvidenceKind.PROVIDER_RESULT, cast("MetadataSource", "locg") + ) + + +def test_evidence_is_immutable() -> None: + evidence = ExactIdentityEvidence(_identity("42"), IdentityEvidenceKind.MYLAR_DATABASE) + with pytest.raises(FrozenInstanceError): + evidence.source_instance = MetadataSource.COMICVINE_API # type: ignore[misc] + + +def test_exact_conflicts_keep_all_origins_and_never_choose_by_input_order() -> None: + observations = [ + ExactIdentityEvidence(_identity("42"), IdentityEvidenceKind.MYLAR_DATABASE), + ExactIdentityEvidence(_identity("43"), IdentityEvidenceKind.SERIES_JSON), + ExactIdentityEvidence( + _identity("42"), IdentityEvidenceKind.PROVIDER_CROSSWALK, MetadataSource.METRON_API + ), + ] + expected = find_exact_identity_conflicts(observations) + assert len(expected) == 1 + assert expected[0].namespace is IdentityNamespace.COMICVINE + assert expected[0].entity_kind is MetadataEntityKind.SERIES + assert set(expected[0].evidence) == set(observations) + for ordered in permutations(observations): + assert find_exact_identity_conflicts(iter(ordered)) == expected + + +def test_repeated_observations_are_idempotent_without_losing_distinct_provenance() -> None: + first = ExactIdentityEvidence(_identity("42"), IdentityEvidenceKind.MYLAR_DATABASE) + second = ExactIdentityEvidence(_identity("43"), IdentityEvidenceKind.COMICINFO_XML) + assert find_exact_identity_conflicts([first, first, second]) == find_exact_identity_conflicts( + [first, second] + ) + + +def test_series_issue_and_story_arc_conflicts_are_separate_and_stably_ordered() -> None: + observations = [ + ExactIdentityEvidence( + _identity(value, namespace, kind), IdentityEvidenceKind.PROVIDER_RESULT + ) + for namespace in (IdentityNamespace.GCD, IdentityNamespace.COMICVINE) + for kind in MetadataEntityKind + for value in ("42", "43") + ] + conflicts = find_exact_identity_conflicts(observations) + assert len(conflicts) == 6 + assert conflicts == find_exact_identity_conflicts(reversed(observations)) + assert {(item.namespace, item.entity_kind) for item in conflicts} == { + (namespace, kind) + for namespace in (IdentityNamespace.GCD, IdentityNamespace.COMICVINE) + for kind in MetadataEntityKind + } + + +def test_different_providers_or_kinds_are_not_same_provider_conflicts() -> None: + observations = [ + ExactIdentityEvidence( + _identity(str(index + 1), namespace, kind), IdentityEvidenceKind.PROVIDER_RESULT + ) + for index, (namespace, kind) in enumerate( + (namespace, kind) for namespace in IdentityNamespace for kind in MetadataEntityKind + ) + ] + assert find_exact_identity_conflicts(observations) == () + # Lack of disagreement is not a crosswalk or authorization to attach these keys. + assert len({item.identity for item in observations}) == 12 + + +def test_empty_evidence_has_no_conflicts() -> None: + assert find_exact_identity_conflicts([]) == () diff --git a/tests/unit/test_performance_benchmark_scripts.py b/tests/unit/test_performance_benchmark_scripts.py index 4cd3c7c8..8f3a5178 100644 --- a/tests/unit/test_performance_benchmark_scripts.py +++ b/tests/unit/test_performance_benchmark_scripts.py @@ -80,6 +80,25 @@ def _repo_root() -> Path: return Path(__file__).resolve().parents[2] +def test_mylar_benchmark_confirms_paths_and_reaches_review_without_provider_calls() -> None: + report = _run_benchmark( + _repo_root(), + "scripts/benchmark_mylar3_import.py", + "--annual-count", + "1", + files_per_series=2, + ) + + assert report["error"] is None + assert report["final_status"] == "review" + assert report["materialized_series_count"] == report["expected_discovered_series_count"] == 2 + assert report["materialized_file_count"] == report["matched_file_count"] == 2 + assert report["provider_call_count"] == 0 + assert report["archive_safety_inspection_count"] == 2 + assert report["archive_member_list_read_count"] == 0 + assert report["archive_member_payload_read_count"] == 0 + + def test_library_navigation_scale_profiles_match_real_world_collections() -> None: assert NAVIGATION_SCALE_PROFILES == { "ethan": (650, 8_000), From 40ebc47299bdb7ac72e0b4f419b690fbaf3d56a9 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 13:37:31 -0700 Subject: [PATCH 03/97] feat: inventory legacy metadata identities and rehearse ownership --- .../services/metadata_identity_inventory.py | 188 ++++++++++++ .../fixtures/metadata_identity_persistence.py | 58 ++++ .../integration/metadata_identity/conftest.py | 70 +++++ .../test_persistence_contract.py | 221 ++++++++++++++ .../unit/test_metadata_identity_inventory.py | 284 ++++++++++++++++++ 5 files changed, 821 insertions(+) create mode 100644 src/pullbox/services/metadata_identity_inventory.py create mode 100644 tests/fixtures/metadata_identity_persistence.py create mode 100644 tests/integration/metadata_identity/conftest.py create mode 100644 tests/integration/metadata_identity/test_persistence_contract.py create mode 100644 tests/unit/test_metadata_identity_inventory.py diff --git a/src/pullbox/services/metadata_identity_inventory.py b/src/pullbox/services/metadata_identity_inventory.py new file mode 100644 index 00000000..81a6384e --- /dev/null +++ b/src/pullbox/services/metadata_identity_inventory.py @@ -0,0 +1,188 @@ +"""Read-only inventory for rehearsing the provider identity migration.""" + +from __future__ import annotations + +import enum +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from pullbox.core.metadata_identity import ( + ExactIdentityConflict, + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + find_exact_identity_conflicts, +) +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +MAX_INVENTORY_PAGE_SIZE = 500 +MAX_ARC_IDENTITIES_PER_PAGE = 2000 + + +class LegacyIdentityStorage(enum.StrEnum): + COMICVINE_COLUMN = "comicvine_column" + STORY_ARC_RELATION = "story_arc_relation" + + +@dataclass(frozen=True) +class LegacyIdentityObservation: + local_id: int + identity: ExternalIdentityRef + storage: LegacyIdentityStorage + storage_row_id: int + + +@dataclass(frozen=True) +class LegacyIdentityProblem: + local_id: int + storage: LegacyIdentityStorage + storage_row_id: int + code: str + + +@dataclass(frozen=True) +class LegacyIdentityDisagreement: + local_id: int + conflict: ExactIdentityConflict + + +@dataclass(frozen=True) +class LegacyIdentityPage: + entity_kind: MetadataEntityKind + local_ids: tuple[int, ...] + observations: tuple[LegacyIdentityObservation, ...] + problems: tuple[LegacyIdentityProblem, ...] + next_after_id: int | None + has_more: bool + disagreements: tuple[LegacyIdentityDisagreement, ...] = () + + +class IdentityInventoryLimitError(ValueError): + """The page cannot be reported completely within its identity-row budget.""" + + +async def read_legacy_identity_page( + session: AsyncSession, + entity_kind: MetadataEntityKind, + *, + after_id: int = 0, + limit: int = 100, +) -> LegacyIdentityPage: + """Inventory persisted evidence, never grant trust or repair either store. + + This is a keyset page, not a transactionally frozen whole-library snapshot. + Migration rehearsals must use a quiescent copy and check external ownership + across all pages separately. Disagreements here are scoped to one target. + """ + if not isinstance(entity_kind, MetadataEntityKind): + raise ValueError("Unknown metadata entity kind") + if ( + type(limit) is not int + or not 1 <= limit <= MAX_INVENTORY_PAGE_SIZE + or type(after_id) is not int + or after_id < 0 + ): + raise ValueError("Invalid identity inventory page bounds") + models: dict[MetadataEntityKind, type[Series] | type[Issue] | type[StoryArc]] = { + MetadataEntityKind.SERIES: Series, + MetadataEntityKind.ISSUE: Issue, + MetadataEntityKind.STORY_ARC: StoryArc, + } + model = models[entity_kind] + observations: list[LegacyIdentityObservation] = [] + problems: list[LegacyIdentityProblem] = [] + # ORM SELECTs can autoflush unrelated caller mutations, even in a read helper. + with session.no_autoflush: + rows = ( + await session.execute( + select(model.id, model.comicvine_id) + .where(model.id > after_id) + .order_by(model.id) + .limit(limit + 1) + ) + ).all() + page_rows = rows[:limit] + local_ids = tuple(row.id for row in page_rows) + for local_id, value in page_rows: + if value is None: + continue + storage = LegacyIdentityStorage.COMICVINE_COLUMN + if type(value) is not int or value <= 0: + problems.append( + LegacyIdentityProblem(local_id, storage, local_id, "invalid_external_id") + ) + continue + observations.append( + LegacyIdentityObservation( + local_id, + ExternalIdentityRef(IdentityNamespace.COMICVINE, entity_kind, str(value)), + storage, + local_id, + ) + ) + + if entity_kind == MetadataEntityKind.STORY_ARC and local_ids: + arc_rows = ( + await session.execute( + select( + StoryArcExternalIdentity.id, + StoryArcExternalIdentity.story_arc_id, + StoryArcExternalIdentity.source, + StoryArcExternalIdentity.external_id, + ) + .where( + StoryArcExternalIdentity.story_arc_id.in_(local_ids), + StoryArcExternalIdentity.source.in_(tuple(IdentityNamespace)), + StoryArcExternalIdentity.namespace == "story_arc", + ) + .order_by(StoryArcExternalIdentity.story_arc_id, StoryArcExternalIdentity.id) + .limit(MAX_ARC_IDENTITIES_PER_PAGE + 1) + ) + ).all() + if len(arc_rows) > MAX_ARC_IDENTITIES_PER_PAGE: + raise IdentityInventoryLimitError( + "Story arc identity row budget exceeded. Smaller pages may help; " + "an arc that exceeds the budget alone needs a separate inspection." + ) + for row_id, local_id, source, value in arc_rows: + storage = LegacyIdentityStorage.STORY_ARC_RELATION + try: + identity = ExternalIdentityRef(IdentityNamespace(source), entity_kind, value) + except ValueError: + problems.append( + LegacyIdentityProblem(local_id, storage, row_id, "invalid_external_id") + ) + else: + observations.append( + LegacyIdentityObservation(local_id, identity, storage, row_id) + ) + + grouped: dict[int, list[ExactIdentityEvidence]] = defaultdict(list) + for observation in observations: + grouped[observation.local_id].append( + ExactIdentityEvidence(observation.identity, IdentityEvidenceKind.LEGACY_BACKFILL) + ) + disagreements = tuple( + LegacyIdentityDisagreement(local_id, conflict) + for local_id, evidence in sorted(grouped.items()) + for conflict in find_exact_identity_conflicts(evidence) + ) + return LegacyIdentityPage( + entity_kind, + local_ids, + tuple(observations), + tuple(problems), + local_ids[-1] if local_ids else None, + len(rows) > limit, + disagreements, + ) diff --git a/tests/fixtures/metadata_identity_persistence.py b/tests/fixtures/metadata_identity_persistence.py new file mode 100644 index 00000000..456c6a83 --- /dev/null +++ b/tests/fixtures/metadata_identity_persistence.py @@ -0,0 +1,58 @@ +"""MP0 persistence rehearsal only; not an application model or migration.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy import Column, ForeignKey, Index, Integer, MetaData, String, Table, UniqueConstraint + +from pullbox.core.metadata_identity import IdentityNamespace +from pullbox.models.story_arc import StoryArcExternalIdentity + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +type IdentityProbeDatabase = tuple[ + AsyncEngine, async_sessionmaker[AsyncSession], IdentitySchemaProbe +] + + +@dataclass(frozen=True) +class IdentitySchemaProbe: + series: Table + issue: Table + arc_index: Index | None + + +def build_identity_schema_probe() -> IdentitySchemaProbe: + metadata = MetaData() + for name in ("series", "issues"): + Table(name, metadata, Column("id", Integer, primary_key=True)) + identities = [] + for kind, parent in (("series", "series"), ("issue", "issues")): + identities.append( + Table( + f"mp0_{kind}_identities", + metadata, + Column("id", Integer, primary_key=True), + Column( + f"{kind}_id", ForeignKey(f"{parent}.id", ondelete="CASCADE"), nullable=False + ), + Column("identity_namespace", String(50), nullable=False), + Column("external_id", String(255), nullable=False), + UniqueConstraint("identity_namespace", "external_id"), + UniqueConstraint(f"{kind}_id", "identity_namespace"), + ) + ) + arc_table = StoryArcExternalIdentity.__table__.to_metadata(metadata) + canonical = (arc_table.c.namespace == "story_arc") & arc_table.c.source.in_( + tuple(IdentityNamespace) + ) + arc_index = Index( + "mp0_one_provider_identity_per_arc", + arc_table.c.story_arc_id, + arc_table.c.source, + unique=True, + sqlite_where=canonical, + postgresql_where=canonical, + ) + return IdentitySchemaProbe(identities[0], identities[1], arc_index) diff --git a/tests/integration/metadata_identity/conftest.py b/tests/integration/metadata_identity/conftest.py new file mode 100644 index 00000000..8a0fed7d --- /dev/null +++ b/tests/integration/metadata_identity/conftest.py @@ -0,0 +1,70 @@ +"""Disposable database parity lane; never reads runtime database settings.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import pytest +from sqlalchemy import event +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.schema import CreateSchema, DropSchema + +from pullbox.models import Base +from tests.fixtures.metadata_identity_persistence import build_identity_schema_probe + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from pathlib import Path + + from tests.fixtures.metadata_identity_persistence import IdentityProbeDatabase + + +@pytest.fixture(params=["sqlite", "postgresql"]) +async def identity_probe_db( + request: pytest.FixtureRequest, tmp_path: Path +) -> AsyncGenerator[IdentityProbeDatabase, None]: + backend = request.param + if backend == "postgresql": + configured = os.environ.get("PULLBOX_METADATA_TEST_POSTGRES_URL") + if not configured: + pytest.skip("Set PULLBOX_METADATA_TEST_POSTGRES_URL for disposable PostgreSQL parity") + url = make_url(configured) + if ( + url.drivername != "postgresql+asyncpg" + or url.database != "pullbox_metadata_contract_test" + ): + pytest.fail("PostgreSQL identity tests require the dedicated contract-test database") + schema = f"mp0_test_{uuid4().hex}" + admin = create_async_engine(url) + async with admin.begin() as connection: + await connection.execute(CreateSchema(schema)) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": schema}}) + else: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'identity.db'}") + + @event.listens_for(engine.sync_engine, "connect") + def enable_foreign_keys(connection: Any, _record: object) -> None: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=15000") + connection.execute("PRAGMA journal_mode=WAL") + + probe = build_identity_schema_probe() + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.run_sync(probe.series.create) + await connection.run_sync(probe.issue.create) + if probe.arc_index is not None: + await connection.run_sync(probe.arc_index.create) + yield engine, async_sessionmaker(engine, expire_on_commit=False), probe + finally: + await engine.dispose() + if backend == "postgresql": + try: + async with admin.begin() as connection: + await connection.execute(DropSchema(schema, cascade=True)) + finally: + await admin.dispose() diff --git a/tests/integration/metadata_identity/test_persistence_contract.py b/tests/integration/metadata_identity/test_persistence_contract.py new file mode 100644 index 00000000..b8642af5 --- /dev/null +++ b/tests/integration/metadata_identity/test_persistence_contract.py @@ -0,0 +1,221 @@ +"""Prove the proposed ownership shape on real SQLite and PostgreSQL engines.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import delete, func, insert, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity +from pullbox.services.metadata_identity_inventory import read_legacy_identity_page + +if TYPE_CHECKING: + from sqlalchemy import Insert, Table + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from tests.fixtures.metadata_identity_persistence import IdentityProbeDatabase + + +async def _seed( + factory: async_sessionmaker[AsyncSession], +) -> tuple[list[Series], list[Issue], list[StoryArc]]: + async with factory() as session: + series = [ + Series(title=f"Series {index}", sort_title=f"series {index}", comicvine_id=100 + index) + for index in range(2) + ] + session.add_all(series) + await session.flush() + issues = [ + Issue(series_id=row.id, issue_number=1, comicvine_id=200 + index) + for index, row in enumerate(series) + ] + arcs = [StoryArc(name=f"Arc {index}", comicvine_id=300 + index) for index in range(2)] + session.add_all([*issues, *arcs]) + await session.commit() + return series, issues, arcs + + +def _link( + table: Table, target_id: int, namespace: str = "comicvine", external_id: str = "100" +) -> Insert: + key = "series_id" if "series_id" in table.c else "issue_id" + return insert(table).values( + **{key: target_id}, identity_namespace=namespace, external_id=external_id + ) + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +async def test_one_target_can_hold_all_namespaces_without_provider_configuration( + identity_probe_db: IdentityProbeDatabase, kind: str +) -> None: + _engine, factory, probe = identity_probe_db + series, issues, _arcs = await _seed(factory) + target = (series if kind == "series" else issues)[0] + table = getattr(probe, kind) + async with factory.begin() as session: + for namespace in ("comicvine", "metron", "gcd", "locg"): + await session.execute(_link(table, target.id, namespace)) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(table)) == 4 + assert {fk.target_fullname for fk in table.foreign_keys} == { + f"{'series' if kind == 'series' else 'issues'}.id" + } + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +@pytest.mark.parametrize( + "collision", ["same_external_other_target", "same_target_other_external", "missing_target"] +) +async def test_database_rejects_invalid_ownership( + identity_probe_db: IdentityProbeDatabase, kind: str, collision: str +) -> None: + _engine, factory, probe = identity_probe_db + series, issues, _arcs = await _seed(factory) + targets = series if kind == "series" else issues + table = getattr(probe, kind) + async with factory.begin() as session: + await session.execute(_link(table, targets[0].id)) + target_id, external_id = { + "same_external_other_target": (targets[1].id, "100"), + "same_target_other_external": (targets[0].id, "101"), + "missing_target": (999999, "102"), + }[collision] + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_link(table, target_id, external_id=external_id)) + + +async def test_parent_delete_cascades_through_series_and_issue_identities( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, probe = identity_probe_db + series, issues, _arcs = await _seed(factory) + async with factory.begin() as session: + await session.execute(_link(probe.series, series[0].id)) + await session.execute(_link(probe.issue, issues[0].id)) + await session.execute(delete(Series).where(Series.id == series[0].id)) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(probe.series)) == 0 + assert await session.scalar(select(func.count()).select_from(probe.issue)) == 0 + + +async def test_legacy_and_proposed_identity_writes_share_one_transaction( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, probe = identity_probe_db + series, _issues, _arcs = await _seed(factory) + async with factory.begin() as session: + await session.execute(_link(probe.series, series[0].id)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute( + update(Series).where(Series.id == series[1].id).values(comicvine_id=999) + ) + await session.execute(_link(probe.series, series[1].id)) + async with factory() as session: + assert ( + await session.scalar(select(Series.comicvine_id).where(Series.id == series[1].id)) + == 101 + ) + assert await session.scalar(select(func.count()).select_from(probe.series)) == 1 + + +async def test_concurrent_external_ownership_has_one_winner( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, probe = identity_probe_db + series, _issues, _arcs = await _seed(factory) + ready = asyncio.Event() + arrived = 0 + + async def attach(target_id: int) -> str: + nonlocal arrived + arrived += 1 + if arrived == 2: + ready.set() + await ready.wait() + try: + async with factory.begin() as session: + await session.execute(_link(probe.series, target_id)) + except IntegrityError: + return "conflict" + return "attached" + + results = await asyncio.wait_for( + asyncio.gather(*(attach(row.id) for row in series)), timeout=10 + ) + assert sorted(results) == ["attached", "conflict"] + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(probe.series)) == 1 + + +async def test_arc_provider_uniqueness_does_not_collapse_import_scoped_identities( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, _probe = identity_probe_db + _series, _issues, arcs = await _seed(factory) + async with factory.begin() as session: + session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=arcs[0].id, source=source, namespace=namespace, external_id=value + ) + for source, namespace, value in [ + ("comicvine", "story_arc", "300"), + ("gcd", "story_arc", "400"), + ("mylar3", "source-a", "1"), + ("mylar3", "source-a", "2"), + ] + ] + ) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + session.add( + StoryArcExternalIdentity( + story_arc_id=arcs[0].id, + source="comicvine", + namespace="story_arc", + external_id="302", + ) + ) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(StoryArcExternalIdentity)) == 4 + + +async def test_inventory_matches_legacy_columns_and_preserves_arc_disagreement( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, _probe = identity_probe_db + series, issues, arcs = await _seed(factory) + async with factory.begin() as session: + session.add( + StoryArcExternalIdentity( + story_arc_id=arcs[0].id, + source="comicvine", + namespace="story_arc", + external_id="00399", + ) + ) + async with factory() as session: + for kind, targets in [ + (MetadataEntityKind.SERIES, series), + (MetadataEntityKind.ISSUE, issues), + (MetadataEntityKind.STORY_ARC, arcs), + ]: + page = await read_legacy_identity_page(session, kind) + assert page.local_ids == tuple(row.id for row in targets) + assert [item.identity.external_id for item in page.observations[:2]] == [ + str(row.comicvine_id) for row in targets + ] + assert len(page.disagreements) == 1 + assert {item.identity.external_id for item in page.disagreements[0].conflict.evidence} == { + "300", + "399", + } diff --git a/tests/unit/test_metadata_identity_inventory.py b/tests/unit/test_metadata_identity_inventory.py new file mode 100644 index 00000000..20738be5 --- /dev/null +++ b/tests/unit/test_metadata_identity_inventory.py @@ -0,0 +1,284 @@ +"""Legacy identity inventory must preserve evidence without mutating the library.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import pytest +from sqlalchemy import event, select + +from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity +from pullbox.services.metadata_identity_inventory import ( + IdentityInventoryLimitError, + LegacyIdentityStorage, + read_legacy_identity_page, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def _targets( + session: AsyncSession, kind: MetadataEntityKind, values: list[int | None] +) -> list[Series | Issue | StoryArc]: + if kind == MetadataEntityKind.ISSUE: + series = Series(title="Parent", sort_title="parent") + session.add(series) + await session.flush() + rows = [] + for index, value in enumerate(values): + if kind == MetadataEntityKind.SERIES: + row = Series(title=f"Series {index}", sort_title=f"series {index}", comicvine_id=value) + elif kind == MetadataEntityKind.ISSUE: + row = Issue(series_id=series.id, issue_number=index + 1, comicvine_id=value) + else: + row = StoryArc(name=f"Arc {index}", comicvine_id=value) + rows.append(row) + session.add_all(rows) + await session.commit() + return rows + + +@pytest.mark.parametrize("kind", list(MetadataEntityKind)) +async def test_pages_include_null_and_invalid_targets_without_changing_legacy_ids( + db_session: AsyncSession, kind: MetadataEntityKind +) -> None: + targets = await _targets(db_session, kind, [41, None, 0, -3, 42]) + + first = await read_legacy_identity_page(db_session, kind, limit=3) + second = await read_legacy_identity_page( + db_session, kind, after_id=first.next_after_id, limit=3 + ) + empty = await read_legacy_identity_page(db_session, kind, after_id=second.next_after_id) + + assert first.local_ids == tuple(row.id for row in targets[:3]) + assert first.next_after_id == targets[2].id + assert first.has_more is True + assert second.local_ids == tuple(row.id for row in targets[3:]) + assert second.has_more is False + assert empty.local_ids == () + assert empty.next_after_id is None + assert empty.has_more is False + assert [item.identity.external_id for item in first.observations + second.observations] == [ + "41", + "42", + ] + assert {item.identity.namespace for item in first.observations} == {IdentityNamespace.COMICVINE} + assert {item.identity.entity_kind for item in first.observations} == {kind} + problems = first.problems + second.problems + assert [(item.local_id, item.code) for item in problems] == [ + (targets[2].id, "invalid_external_id"), + (targets[3].id, "invalid_external_id"), + ] + assert all(item.storage == LegacyIdentityStorage.COMICVINE_COLUMN for item in problems) + assert [row.comicvine_id for row in targets] == [41, None, 0, -3, 42] + + +async def test_arc_inventory_keeps_disagreement_and_origin_but_excludes_import_scopes( + db_session: AsyncSession, +) -> None: + [arc] = await _targets(db_session, MetadataEntityKind.STORY_ARC, [42]) + rows = [ + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id="00043" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, source="metron", namespace="story_arc", external_id="7" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, source="gcd", namespace="story_arc", external_id="9" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, source="mylar3", namespace="database-a", external_id="42" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="import:private", external_id="99" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, source="folder", namespace="story_arc", external_id="123" + ), + ] + db_session.add_all(rows) + await db_session.commit() + + page = await read_legacy_identity_page(db_session, MetadataEntityKind.STORY_ARC) + + assert [ + (item.identity.namespace.value, item.identity.external_id) for item in page.observations + ] == [("comicvine", "42"), ("comicvine", "43"), ("metron", "7"), ("gcd", "9")] + assert page.observations[0].storage == LegacyIdentityStorage.COMICVINE_COLUMN + assert page.observations[1].storage == LegacyIdentityStorage.STORY_ARC_RELATION + assert page.observations[1].storage_row_id == rows[0].id + assert len(page.disagreements) == 1 + assert page.disagreements[0].local_id == arc.id + assert {item.identity.external_id for item in page.disagreements[0].conflict.evidence} == { + "42", + "43", + } + assert arc.comicvine_id == 42 + assert rows[0].external_id == "00043" + assert len((await db_session.scalars(select(StoryArcExternalIdentity))).all()) == 6 + + +async def test_arc_inventory_retains_agreeing_observations_from_both_storage_paths( + db_session: AsyncSession, +) -> None: + [arc] = await _targets(db_session, MetadataEntityKind.STORY_ARC, [42]) + db_session.add( + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id="00042" + ) + ) + await db_session.commit() + + page = await read_legacy_identity_page(db_session, MetadataEntityKind.STORY_ARC) + + assert len(page.observations) == 2 + assert page.observations[0].identity == page.observations[1].identity + assert {item.storage for item in page.observations} == set(LegacyIdentityStorage) + assert page.disagreements == () + + +async def test_invalid_arc_identity_has_safe_diagnostic_without_raw_url_or_payload( + db_session: AsyncSession, +) -> None: + [arc] = await _targets(db_session, MetadataEntityKind.STORY_ARC, [None]) + raw = "https://example.invalid/?token=private-fixture-value" + relation = StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id=raw + ) + db_session.add(relation) + await db_session.commit() + + page = await read_legacy_identity_page(db_session, MetadataEntityKind.STORY_ARC) + + assert page.observations == () + assert len(page.problems) == 1 + assert page.problems[0].storage_row_id == relation.id + assert page.problems[0].code == "invalid_external_id" + assert "private-fixture-value" not in repr(page) + + +async def test_inventory_reads_persisted_values_without_flush_commit_or_orm_hydration( + db_session: AsyncSession, +) -> None: + [series] = await _targets(db_session, MetadataEntityKind.SERIES, [42]) + series.comicvine_id = 43 + pending = Series(title="Pending", sort_title="pending", comicvine_id=44) + db_session.add(pending) + before_map = set(db_session.identity_map) + statements = [] + + def record( + _conn: object, + _cursor: object, + statement: str, + _params: object, + _context: object, + _many: bool, + ) -> None: + statements.append(statement) + + event.listen(db_session.bind.sync_engine, "before_cursor_execute", record) + try: + page = await read_legacy_identity_page(db_session, MetadataEntityKind.SERIES) + finally: + event.remove(db_session.bind.sync_engine, "before_cursor_execute", record) + + assert [item.identity.external_id for item in page.observations] == ["42"] + assert len(statements) == 1 + assert statements[0].lstrip().upper().startswith("SELECT") + assert set(db_session.identity_map) == before_map + assert pending.id is None + await db_session.rollback() + assert (await db_session.scalar(select(Series.comicvine_id))) == 42 + + +async def test_arc_inventory_is_bounded_bulk_read_and_only_loads_the_requested_page( + db_session: AsyncSession, +) -> None: + arcs = await _targets(db_session, MetadataEntityKind.STORY_ARC, [None] * 30) + db_session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id=str(1000 + arc.id), + ) + for arc in arcs + ] + ) + await db_session.commit() + db_session.expunge_all() + statements = [] + + def record( + _conn: object, + _cursor: object, + statement: str, + _params: object, + _context: object, + _many: bool, + ) -> None: + statements.append(statement) + + event.listen(db_session.bind.sync_engine, "before_cursor_execute", record) + try: + page = await read_legacy_identity_page( + db_session, MetadataEntityKind.STORY_ARC, after_id=arcs[9].id, limit=10 + ) + finally: + event.remove(db_session.bind.sync_engine, "before_cursor_execute", record) + + assert page.local_ids == tuple(arc.id for arc in arcs[10:20]) + assert {item.local_id for item in page.observations} == set(page.local_ids) + assert len(page.observations) == 10 + assert len(statements) == 2 + assert not db_session.identity_map + + +async def test_arc_inventory_refuses_silent_relation_truncation(db_session: AsyncSession) -> None: + [arc] = await _targets(db_session, MetadataEntityKind.STORY_ARC, [None]) + db_session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id=str(index + 1), + ) + for index in range(2001) + ] + ) + await db_session.commit() + + with pytest.raises(IdentityInventoryLimitError, match="Smaller pages"): + await read_legacy_identity_page(db_session, MetadataEntityKind.STORY_ARC, limit=1) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": 0}, + {"limit": 501}, + {"limit": True}, + {"limit": 2.5}, + {"after_id": -1}, + {"after_id": True}, + {"after_id": "1"}, + ], +) +async def test_invalid_inventory_paging_is_rejected( + db_session: AsyncSession, kwargs: dict[str, object] +) -> None: + with pytest.raises(ValueError, match="page"): + await read_legacy_identity_page(db_session, MetadataEntityKind.SERIES, **kwargs) + + +async def test_unknown_entity_kind_is_rejected(db_session: AsyncSession) -> None: + with pytest.raises(ValueError, match="entity kind"): + await read_legacy_identity_page(db_session, cast("MetadataEntityKind", "publisher")) From 1b65fa16614a61d09cb6d0e845970042cc8da63f Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 14:12:57 -0700 Subject: [PATCH 04/97] feat: define metadata identity lifecycle and history contracts --- src/pullbox/core/metadata_identity_state.py | 62 +++++ .../fixtures/metadata_identity_persistence.py | 76 +++++- .../integration/metadata_identity/conftest.py | 2 + .../test_evidence_history_contract.py | 233 ++++++++++++++++++ .../test_persistence_contract.py | 5 +- tests/unit/test_metadata_identity_state.py | 76 ++++++ 6 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 src/pullbox/core/metadata_identity_state.py create mode 100644 tests/integration/metadata_identity/test_evidence_history_contract.py create mode 100644 tests/unit/test_metadata_identity_state.py diff --git a/src/pullbox/core/metadata_identity_state.py b/src/pullbox/core/metadata_identity_state.py new file mode 100644 index 00000000..67d2588b --- /dev/null +++ b/src/pullbox/core/metadata_identity_state.py @@ -0,0 +1,62 @@ +"""Identity evidence lifecycle; state changes do not authorize attachment.""" + +import enum +from typing import assert_never + + +class IdentityVerificationState(enum.StrEnum): + OBSERVED = "observed" + VERIFIED = "verified" + CONFLICTED = "conflicted" + STALE = "stale" + REJECTED = "rejected" + + +class IdentityVerificationAction(enum.StrEnum): + OBSERVE = "observe" + VERIFY = "verify" + REPORT_CONFLICT = "report_conflict" + MARK_STALE = "mark_stale" + REJECT = "reject" + CONFIRM = "confirm" + + +class IdentityReviewRequiredError(ValueError): + """An automatic verification cannot replace an unresolved human decision.""" + + +def transition_identity_state( + state: IdentityVerificationState, action: IdentityVerificationAction +) -> IdentityVerificationState: + """Apply a validated lifecycle action without database or provider work. + + Callers must validate evidence before VERIFY and explicit user intent before + CONFIRM or REJECT. Enum values are not authorization tokens. A verified state + does not prove cross-provider agreement, parent membership, or uniqueness. + Those checks and any attachment/history writes belong in one transaction. + """ + if not isinstance(state, IdentityVerificationState): + raise ValueError("Unknown identity verification state") + if not isinstance(action, IdentityVerificationAction): + raise ValueError("Unknown identity verification action") + match action: + case IdentityVerificationAction.OBSERVE: + return state + case IdentityVerificationAction.VERIFY: + if state in (IdentityVerificationState.CONFLICTED, IdentityVerificationState.REJECTED): + raise IdentityReviewRequiredError("This identity requires explicit review") + return IdentityVerificationState.VERIFIED + case IdentityVerificationAction.CONFIRM: + return IdentityVerificationState.VERIFIED + case IdentityVerificationAction.REJECT: + return IdentityVerificationState.REJECTED + case IdentityVerificationAction.REPORT_CONFLICT: + if state is IdentityVerificationState.REJECTED: + return state + return IdentityVerificationState.CONFLICTED + case IdentityVerificationAction.MARK_STALE: + if state in (IdentityVerificationState.REJECTED, IdentityVerificationState.CONFLICTED): + return state + return IdentityVerificationState.STALE + case _: + assert_never(action) diff --git a/tests/fixtures/metadata_identity_persistence.py b/tests/fixtures/metadata_identity_persistence.py index 456c6a83..66bcd58f 100644 --- a/tests/fixtures/metadata_identity_persistence.py +++ b/tests/fixtures/metadata_identity_persistence.py @@ -1,11 +1,30 @@ """MP0 persistence rehearsal only; not an application model or migration.""" +import enum from dataclasses import dataclass +from datetime import UTC, datetime from typing import TYPE_CHECKING -from sqlalchemy import Column, ForeignKey, Index, Integer, MetaData, String, Table, UniqueConstraint +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKey, + Index, + Integer, + MetaData, + String, + Table, + UniqueConstraint, +) +from sqlalchemy import Enum as SQLAlchemyEnum -from pullbox.core.metadata_identity import IdentityNamespace +from pullbox.core.metadata_identity import ( + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models.base import UTCDateTime from pullbox.models.story_arc import StoryArcExternalIdentity if TYPE_CHECKING: @@ -21,11 +40,22 @@ class IdentitySchemaProbe: series: Table issue: Table arc_index: Index | None + events: dict[MetadataEntityKind, Table] + + +def _stored_enum(enum_class: type[enum.StrEnum], name: str) -> SQLAlchemyEnum: + return SQLAlchemyEnum( + enum_class, + name=name, + native_enum=False, + create_constraint=True, + values_callable=lambda members: [member.value for member in members], + ) def build_identity_schema_probe() -> IdentitySchemaProbe: metadata = MetaData() - for name in ("series", "issues"): + for name in ("series", "issues", "story_arcs"): Table(name, metadata, Column("id", Integer, primary_key=True)) identities = [] for kind, parent in (("series", "series"), ("issue", "issues")): @@ -39,6 +69,15 @@ def build_identity_schema_probe() -> IdentitySchemaProbe: ), Column("identity_namespace", String(50), nullable=False), Column("external_id", String(255), nullable=False), + Column( + "verification_state", + _stored_enum(IdentityVerificationState, f"mp0_{kind}_owner_state"), + nullable=False, + ), + CheckConstraint( + "verification_state IN ('verified', 'stale', 'conflicted')", + name=f"mp0_{kind}_attached_states", + ), UniqueConstraint("identity_namespace", "external_id"), UniqueConstraint(f"{kind}_id", "identity_namespace"), ) @@ -55,4 +94,33 @@ def build_identity_schema_probe() -> IdentitySchemaProbe: sqlite_where=canonical, postgresql_where=canonical, ) - return IdentitySchemaProbe(identities[0], identities[1], arc_index) + events = {} + for kind, parent in ( + (MetadataEntityKind.SERIES, "series"), + (MetadataEntityKind.ISSUE, "issues"), + (MetadataEntityKind.STORY_ARC, "story_arcs"), + ): + events[kind] = Table( + f"mp0_{kind.value}_identity_events", + metadata, + Column("id", Integer, primary_key=True), + Column( + f"{kind.value}_id", ForeignKey(f"{parent}.id", ondelete="CASCADE"), nullable=False + ), + Column("identity_namespace", String(50), nullable=False), + Column("external_id", String(255), nullable=False), + Column("event_key", String(64), nullable=False), + Column( + "verification_state", + _stored_enum(IdentityVerificationState, f"mp0_{kind.value}_event_state"), + nullable=False, + ), + Column( + "evidence_kind", + _stored_enum(IdentityEvidenceKind, f"mp0_{kind.value}_event_evidence"), + nullable=False, + ), + Column("created_at", UTCDateTime, nullable=False, default=lambda: datetime.now(UTC)), + UniqueConstraint(f"{kind.value}_id", "event_key"), + ) + return IdentitySchemaProbe(identities[0], identities[1], arc_index, events) diff --git a/tests/integration/metadata_identity/conftest.py b/tests/integration/metadata_identity/conftest.py index 8a0fed7d..b33d818f 100644 --- a/tests/integration/metadata_identity/conftest.py +++ b/tests/integration/metadata_identity/conftest.py @@ -59,6 +59,8 @@ def enable_foreign_keys(connection: Any, _record: object) -> None: await connection.run_sync(probe.issue.create) if probe.arc_index is not None: await connection.run_sync(probe.arc_index.create) + for table in probe.events.values(): + await connection.run_sync(table.create) yield engine, async_sessionmaker(engine, expire_on_commit=False), probe finally: await engine.dispose() diff --git a/tests/integration/metadata_identity/test_evidence_history_contract.py b/tests/integration/metadata_identity/test_evidence_history_contract.py new file mode 100644 index 00000000..5defa102 --- /dev/null +++ b/tests/integration/metadata_identity/test_evidence_history_contract.py @@ -0,0 +1,233 @@ +"""Rejected evidence is durable but does not reserve canonical ownership.""" + +from __future__ import annotations + +from datetime import UTC +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import delete, func, insert, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity_state import IdentityVerificationState as State +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity + +if TYPE_CHECKING: + from sqlalchemy import Insert, Table + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from tests.fixtures.metadata_identity_persistence import ( + IdentityProbeDatabase, + IdentitySchemaProbe, + ) + + +async def _targets(factory: async_sessionmaker[AsyncSession], kind: Kind) -> list[int]: + async with factory.begin() as session: + series = [ + Series(title=f"History {index}", sort_title=f"history {index}") for index in range(2) + ] + session.add_all(series) + await session.flush() + if kind is Kind.SERIES: + return [row.id for row in series] + rows = ( + [Issue(series_id=row.id, issue_number=1) for row in series] + if kind is Kind.ISSUE + else [StoryArc(name=f"History arc {index}") for index in range(2)] + ) + session.add_all(rows) + await session.flush() + return [row.id for row in rows] + + +def _event( + table: Table, + kind: Kind, + target: int, + state: State = State.OBSERVED, + *, + key: str = "a" * 64, + external_id: str = "100", +) -> Insert: + return insert(table).values( + **{f"{kind.value}_id": target}, + identity_namespace="comicvine", + external_id=external_id, + event_key=key, + verification_state=state.value, + evidence_kind="user_selection" if state is State.REJECTED else "comicinfo_xml", + ) + + +def _ownership(probe: IdentitySchemaProbe, kind: Kind) -> Table: + if kind is Kind.STORY_ARC: + return StoryArcExternalIdentity.__table__ + return probe.series if kind is Kind.SERIES else probe.issue + + +def _attach(table: Table, kind: Kind, target: int, state: State = State.VERIFIED) -> Insert: + if kind is Kind.STORY_ARC: + return insert(table).values( + story_arc_id=target, source="comicvine", namespace="story_arc", external_id="100" + ) + return insert(table).values( + **{f"{kind.value}_id": target}, + identity_namespace="comicvine", + external_id="100", + verification_state=state.value, + ) + + +@pytest.mark.parametrize("kind", list(Kind)) +@pytest.mark.parametrize("state", [State.OBSERVED, State.CONFLICTED, State.REJECTED]) +async def test_unattached_evidence_never_reserves_an_external_identity( + identity_probe_db: IdentityProbeDatabase, kind: Kind, state: State +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + events, ownership = probe.events[kind], _ownership(probe, kind) + async with factory.begin() as session: + for target in targets: + await session.execute(_event(events, kind, target, state)) + await session.execute(_attach(ownership, kind, targets[1])) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(events)) == 2 + assert await session.scalar(select(func.count()).select_from(ownership)) == 1 + assert (await session.execute(select(events.c.verification_state))).scalars().all() == [ + state, + state, + ] + + +@pytest.mark.parametrize("kind", [Kind.SERIES, Kind.ISSUE]) +@pytest.mark.parametrize("state", [State.OBSERVED, State.REJECTED]) +async def test_observed_or_rejected_claims_cannot_be_active_attachments( + identity_probe_db: IdentityProbeDatabase, kind: Kind, state: State +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_attach(_ownership(probe, kind), kind, targets[0], state)) + + +@pytest.mark.parametrize("kind", [Kind.SERIES, Kind.ISSUE]) +@pytest.mark.parametrize("state", [State.STALE, State.CONFLICTED]) +async def test_stale_or_disputed_attachment_retains_ownership_until_explicit_resolution( + identity_probe_db: IdentityProbeDatabase, kind: Kind, state: State +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + ownership = _ownership(probe, kind) + async with factory.begin() as session: + await session.execute(_attach(ownership, kind, targets[0])) + await session.execute(update(ownership).values(verification_state=state.value)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_attach(ownership, kind, targets[1])) + async with factory() as session: + assert await session.scalar(select(ownership.c.verification_state)) == state + assert await session.scalar(select(ownership.c[f"{kind.value}_id"])) == targets[0] + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_detaching_does_not_erase_rejection_history_or_block_correct_owner( + identity_probe_db: IdentityProbeDatabase, kind: Kind +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + events, ownership = probe.events[kind], _ownership(probe, kind) + async with factory.begin() as session: + await session.execute(_attach(ownership, kind, targets[0])) + await session.execute(_event(events, kind, targets[0], State.REJECTED)) + await session.execute( + delete(ownership).where(ownership.c[f"{kind.value}_id"] == targets[0]) + ) + await session.execute(_attach(ownership, kind, targets[1])) + async with factory() as session: + assert await session.scalar(select(events.c.verification_state)) == State.REJECTED + assert await session.scalar(select(events.c[f"{kind.value}_id"])) == targets[0] + assert await session.scalar(select(ownership.c[f"{kind.value}_id"])) == targets[1] + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_history_requires_a_real_parent_and_cascades_only_with_that_parent( + identity_probe_db: IdentityProbeDatabase, kind: Kind +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + table = probe.events[kind] + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_event(table, kind, 999999)) + parent = {Kind.SERIES: Series, Kind.ISSUE: Issue, Kind.STORY_ARC: StoryArc}[kind] + async with factory.begin() as session: + for target in targets: + await session.execute(_event(table, kind, target)) + await session.execute(delete(parent).where(parent.id == targets[0])) + async with factory() as session: + assert (await session.execute(select(table.c[f"{kind.value}_id"]))).scalars().all() == [ + targets[1] + ] + assert {fk.target_fullname for fk in table.foreign_keys} == {f"{parent.__tablename__}.id"} + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_retry_key_is_unique_per_target_but_new_decision_preserves_old_history( + identity_probe_db: IdentityProbeDatabase, kind: Kind +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + table = probe.events[kind] + async with factory.begin() as session: + await session.execute(_event(table, kind, targets[0], State.REJECTED)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_event(table, kind, targets[0], State.VERIFIED)) + async with factory.begin() as session: + await session.execute(_event(table, kind, targets[0], State.VERIFIED, key="b" * 64)) + async with factory() as session: + rows = (await session.execute(select(table).order_by(table.c.id))).all() + assert [row.verification_state for row in rows] == [State.REJECTED, State.VERIFIED] + assert all(row.created_at.tzinfo is UTC for row in rows) + + +@pytest.mark.parametrize("kind", list(Kind)) +@pytest.mark.parametrize( + "column,value", [("verification_state", "trusted"), ("evidence_kind", "anything")] +) +async def test_history_rejects_unrecognized_state_or_evidence_kind( + identity_probe_db: IdentityProbeDatabase, kind: Kind, column: str, value: str +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute( + _event(probe.events[kind], kind, targets[0]).values(**{column: value}) + ) + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_failed_attachment_rolls_back_its_history_without_losing_prior_rejection( + identity_probe_db: IdentityProbeDatabase, kind: Kind +) -> None: + _engine, factory, probe = identity_probe_db + targets = await _targets(factory, kind) + events, ownership = probe.events[kind], _ownership(probe, kind) + async with factory.begin() as session: + await session.execute(_attach(ownership, kind, targets[0])) + await session.execute(_event(events, kind, targets[1], State.REJECTED)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(_event(events, kind, targets[1], State.VERIFIED, key="b" * 64)) + await session.execute(_attach(ownership, kind, targets[1])) + async with factory() as session: + assert (await session.execute(select(events.c.verification_state))).scalars().all() == [ + State.REJECTED + ] + assert await session.scalar(select(ownership.c[f"{kind.value}_id"])) == targets[0] diff --git a/tests/integration/metadata_identity/test_persistence_contract.py b/tests/integration/metadata_identity/test_persistence_contract.py index b8642af5..71f63cf3 100644 --- a/tests/integration/metadata_identity/test_persistence_contract.py +++ b/tests/integration/metadata_identity/test_persistence_contract.py @@ -47,7 +47,10 @@ def _link( ) -> Insert: key = "series_id" if "series_id" in table.c else "issue_id" return insert(table).values( - **{key: target_id}, identity_namespace=namespace, external_id=external_id + **{key: target_id}, + identity_namespace=namespace, + external_id=external_id, + verification_state="verified", ) diff --git a/tests/unit/test_metadata_identity_state.py b/tests/unit/test_metadata_identity_state.py new file mode 100644 index 00000000..5b0cee0f --- /dev/null +++ b/tests/unit/test_metadata_identity_state.py @@ -0,0 +1,76 @@ +"""Verification is explicit, while unresolved conflicts and rejections persist.""" + +from typing import cast + +import pytest + +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + transition_identity_state, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationAction as Action, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationState as State, +) + + +@pytest.mark.parametrize("state", list(State)) +def test_observation_alone_never_changes_verification_or_rejection(state: State) -> None: + assert transition_identity_state(state, Action.OBSERVE) is state + + +@pytest.mark.parametrize("state", [State.OBSERVED, State.VERIFIED, State.STALE]) +def test_validated_verification_can_establish_or_refresh_identity(state: State) -> None: + assert transition_identity_state(state, Action.VERIFY) is State.VERIFIED + + +@pytest.mark.parametrize("state", [State.CONFLICTED, State.REJECTED]) +def test_automatic_verification_cannot_clear_conflict_or_user_rejection(state: State) -> None: + with pytest.raises(IdentityReviewRequiredError): + transition_identity_state(state, Action.VERIFY) + + +@pytest.mark.parametrize("state", list(State)) +def test_conflict_does_not_override_an_existing_rejection(state: State) -> None: + expected = State.REJECTED if state is State.REJECTED else State.CONFLICTED + assert transition_identity_state(state, Action.REPORT_CONFLICT) is expected + + +@pytest.mark.parametrize("state", list(State)) +def test_provider_missing_or_expiry_does_not_clear_review_state(state: State) -> None: + expected = state if state in (State.REJECTED, State.CONFLICTED) else State.STALE + assert transition_identity_state(state, Action.MARK_STALE) is expected + + +@pytest.mark.parametrize("state", list(State)) +def test_explicit_rejection_is_durable(state: State) -> None: + assert transition_identity_state(state, Action.REJECT) is State.REJECTED + + +@pytest.mark.parametrize("state", list(State)) +def test_explicit_review_confirmation_can_resolve_a_proposal(state: State) -> None: + assert transition_identity_state(state, Action.CONFIRM) is State.VERIFIED + + +@pytest.mark.parametrize("invalid", [None, "verified", "not-a-state", 1, True]) +def test_unknown_states_are_not_treated_as_verified(invalid: object) -> None: + with pytest.raises(ValueError, match="state"): + transition_identity_state(cast("State", invalid), Action.CONFIRM) + + +@pytest.mark.parametrize("invalid", [None, "verify", "not-an-action", 1, True]) +def test_unvalidated_action_strings_are_not_accepted(invalid: object) -> None: + with pytest.raises(ValueError, match="action"): + transition_identity_state(State.OBSERVED, cast("Action", invalid)) + + +def test_rescan_and_provider_refresh_cannot_undo_a_user_rejection() -> None: + state = transition_identity_state(State.OBSERVED, Action.REJECT) + for action in (Action.OBSERVE, Action.REPORT_CONFLICT, Action.MARK_STALE): + state = transition_identity_state(state, action) + assert state is State.REJECTED + with pytest.raises(IdentityReviewRequiredError): + transition_identity_state(state, Action.VERIFY) + assert transition_identity_state(state, Action.CONFIRM) is State.VERIFIED From 515ff110b417cefc8933b532d6042c68457543d0 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 17:13:29 -0700 Subject: [PATCH 05/97] feat: audit legacy metadata identity ownership across pages --- .../services/metadata_identity_audit.py | 180 ++++++++++ .../test_legacy_audit_contract.py | 123 +++++++ tests/unit/test_metadata_identity_audit.py | 315 ++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 src/pullbox/services/metadata_identity_audit.py create mode 100644 tests/integration/metadata_identity/test_legacy_audit_contract.py create mode 100644 tests/unit/test_metadata_identity_audit.py diff --git a/src/pullbox/services/metadata_identity_audit.py b/src/pullbox/services/metadata_identity_audit.py new file mode 100644 index 00000000..3f663794 --- /dev/null +++ b/src/pullbox/services/metadata_identity_audit.py @@ -0,0 +1,180 @@ +"""Bounded, read-only ownership preflight for legacy identity migration rehearsals.""" + +from __future__ import annotations + +import enum +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.services.metadata_identity_inventory import ( + MAX_INVENTORY_PAGE_SIZE, + IdentityInventoryLimitError, + LegacyIdentityDisagreement, + LegacyIdentityObservation, + LegacyIdentityProblem, + LegacyIdentityStorage, + read_legacy_identity_page, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from pullbox.core.metadata_identity import ExternalIdentityRef + +MAX_AUDIT_TARGETS = 1_000_000 +MAX_AUDIT_EVIDENCE_ROWS = 2_000_000 + + +class IdentityAuditStopReason(enum.StrEnum): + TARGET_LIMIT = "target_limit" + EVIDENCE_LIMIT = "evidence_limit" + PAGE_EVIDENCE_LIMIT = "page_evidence_limit" + + +@dataclass(frozen=True) +class LegacyIdentityOwnershipCollision: + identity: ExternalIdentityRef + claims: tuple[LegacyIdentityObservation, ...] + + +@dataclass(frozen=True) +class LegacyIdentityDuplicateRelations: + identity: ExternalIdentityRef + local_id: int + storage_row_ids: tuple[int, ...] + + +@dataclass(frozen=True) +class LegacyIdentityAudit: + """Checked-prefix evidence; completion is scoped to this one entity kind.""" + + entity_kind: MetadataEntityKind + targets_checked: int + observations_checked: int + collisions: tuple[LegacyIdentityOwnershipCollision, ...] = () + duplicate_relations: tuple[LegacyIdentityDuplicateRelations, ...] = () + problems: tuple[LegacyIdentityProblem, ...] = () + disagreements: tuple[LegacyIdentityDisagreement, ...] = () + stop_reason: IdentityAuditStopReason | None = None + + @property + def complete(self) -> bool: + return self.stop_reason is None + + @property + def evidence_rows_checked(self) -> int: + return self.observations_checked + len(self.problems) + + @property + def has_blockers(self) -> bool: + return bool( + not self.complete + or self.collisions + or self.duplicate_relations + or self.problems + or self.disagreements + ) + + +async def audit_legacy_identities( + session: AsyncSession, + entity_kind: MetadataEntityKind, + *, + page_size: int = 100, + max_targets: int = 250_000, + max_evidence_rows: int = 500_000, +) -> LegacyIdentityAudit: + """Audit one entity kind on a quiescent copy, not a live migration approval. + + No repair, attachment, provider call, commit, or snapshot isolation is implied. + Scan every kind separately on the same stable copy before planning backfill. + Findings are deterministic across page sizes when the traversal completes. + An over-budget page is excluded entirely; retained counts/findings describe + only fully checked pages. Incomplete reports must never authorize a backfill. + """ + if not isinstance(entity_kind, MetadataEntityKind): + raise ValueError("Unknown metadata entity kind") + for value, maximum in ( + (page_size, MAX_INVENTORY_PAGE_SIZE), + (max_targets, MAX_AUDIT_TARGETS), + (max_evidence_rows, MAX_AUDIT_EVIDENCE_ROWS), + ): + if type(value) is not int or not 1 <= value <= maximum: + raise ValueError("Invalid identity audit bounds") + + claims: dict[ExternalIdentityRef, list[LegacyIdentityObservation]] = defaultdict(list) + problems: list[LegacyIdentityProblem] = [] + disagreements: list[LegacyIdentityDisagreement] = [] + targets_checked = observations_checked = after_id = 0 + stop_reason = None + while True: + try: + page = await read_legacy_identity_page( + session, + entity_kind, + after_id=after_id, + limit=min(page_size, max_targets - targets_checked), + ) + except IdentityInventoryLimitError: + stop_reason = IdentityAuditStopReason.PAGE_EVIDENCE_LIMIT + break + evidence_rows = ( + observations_checked + len(problems) + len(page.observations) + len(page.problems) + ) + if evidence_rows > max_evidence_rows: + stop_reason = IdentityAuditStopReason.EVIDENCE_LIMIT + break + targets_checked += len(page.local_ids) + observations_checked += len(page.observations) + problems.extend(page.problems) + disagreements.extend(page.disagreements) + for observation in page.observations: + claims[observation.identity].append(observation) + if not page.has_more: + break + if targets_checked == max_targets: + stop_reason = IdentityAuditStopReason.TARGET_LIMIT + break + if page.next_after_id is None or page.next_after_id <= after_id: + raise ValueError("Identity inventory cursor did not advance") + after_id = page.next_after_id + + collisions, duplicates = _ownership_findings(claims) + return LegacyIdentityAudit( + entity_kind=entity_kind, + targets_checked=targets_checked, + observations_checked=observations_checked, + collisions=collisions, + duplicate_relations=duplicates, + problems=tuple(sorted(problems, key=lambda p: (p.local_id, p.storage, p.storage_row_id))), + disagreements=tuple(disagreements), + stop_reason=stop_reason, + ) + + +def _ownership_findings( + claims: dict[ExternalIdentityRef, list[LegacyIdentityObservation]], +) -> tuple[ + tuple[LegacyIdentityOwnershipCollision, ...], tuple[LegacyIdentityDuplicateRelations, ...] +]: + collisions = [] + duplicates = [] + for identity in sorted(claims, key=lambda key: (key.namespace, key.external_id)): + observations = sorted( + claims[identity], + key=lambda claim: (claim.local_id, claim.storage, claim.storage_row_id), + ) + if len({claim.local_id for claim in observations}) > 1: + collisions.append(LegacyIdentityOwnershipCollision(identity, tuple(observations))) + relations: dict[int, list[int]] = defaultdict(list) + for claim in observations: + if claim.storage == LegacyIdentityStorage.STORY_ARC_RELATION: + relations[claim.local_id].append(claim.storage_row_id) + for local_id, row_ids in relations.items(): + if len(row_ids) > 1: + duplicates.append( + LegacyIdentityDuplicateRelations(identity, local_id, tuple(row_ids)) + ) + return tuple(collisions), tuple(duplicates) diff --git a/tests/integration/metadata_identity/test_legacy_audit_contract.py b/tests/integration/metadata_identity/test_legacy_audit_contract.py new file mode 100644 index 00000000..7a5b8667 --- /dev/null +++ b/tests/integration/metadata_identity/test_legacy_audit_contract.py @@ -0,0 +1,123 @@ +"""Legacy ownership preflight must give the same read-only answer on both databases.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity +from pullbox.services.metadata_identity_audit import ( + IdentityAuditStopReason, + audit_legacy_identities, +) + +if TYPE_CHECKING: + from tests.fixtures.metadata_identity_persistence import IdentityProbeDatabase + + +async def test_normalized_ownership_crosses_pages_but_not_entity_kinds( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, _probe = identity_probe_db + async with factory.begin() as session: + series = Series(title="Same numeric key", sort_title="same numeric key", comicvine_id=42) + session.add(series) + await session.flush() + session.add(Issue(series_id=series.id, issue_number=1, comicvine_id=42)) + arcs = [ + StoryArc(name=f"Arc {i}", comicvine_id=value) for i, value in enumerate([42, None, 99]) + ] + session.add_all(arcs) + await session.flush() + session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id=value, + ) + for arc, value in zip(arcs, ["00042", " 42 ", "98"], strict=True) + ] + ) + async with factory() as session: + before = (await session.execute(select(StoryArcExternalIdentity.__table__))).all() + for kind in (MetadataEntityKind.SERIES, MetadataEntityKind.ISSUE): + report = await audit_legacy_identities(session, kind, page_size=1) + assert report.targets_checked == report.observations_checked == 1 + assert report.complete and not report.has_blockers + report = await audit_legacy_identities(session, MetadataEntityKind.STORY_ARC, page_size=1) + assert report == await audit_legacy_identities( + session, MetadataEntityKind.STORY_ARC, page_size=500 + ) + assert report.complete and report.has_blockers + assert report.targets_checked == 3 and report.observations_checked == 5 + assert len(report.collisions) == len(report.disagreements) == 1 + assert report.collisions[0].identity.external_id == "42" + assert {claim.local_id for claim in report.collisions[0].claims} == {arcs[0].id, arcs[1].id} + assert report.disagreements[0].local_id == arcs[2].id + assert (await session.execute(select(StoryArcExternalIdentity.__table__))).all() == before + assert not session.identity_map + + +async def test_duplicate_normalized_legacy_rows_are_reported_not_deleted( + identity_probe_db: IdentityProbeDatabase, +) -> None: + engine, factory, probe = identity_probe_db + # Reproduce pre-migration rows: the proposed partial index is not in the app schema. + assert probe.arc_index is not None + async with engine.begin() as connection: + await connection.run_sync(probe.arc_index.drop) + async with factory.begin() as session: + arc = StoryArc(name="Duplicate legacy rows", comicvine_id=42) + session.add(arc) + await session.flush() + relations = [ + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id=value + ) + for value in ["42", "00042"] + ] + session.add_all(relations) + async with factory() as session: + report = await audit_legacy_identities(session, MetadataEntityKind.STORY_ARC) + assert report.complete and report.has_blockers + assert report.collisions == report.disagreements == () + assert report.duplicate_relations[0].storage_row_ids == tuple(row.id for row in relations) + assert ( + await session.scalars( + select(StoryArcExternalIdentity.external_id).order_by(StoryArcExternalIdentity.id) + ) + ).all() == ["42", "00042"] + + +async def test_partial_audit_preserves_invalid_evidence_and_does_not_flush_pending_changes( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, _probe = identity_probe_db + async with factory.begin() as session: + arcs = [StoryArc(name=f"Arc {i}", comicvine_id=value) for i, value in enumerate([0, 42])] + session.add_all(arcs) + async with factory() as session: + first = await session.get(StoryArc, arcs[0].id) + assert first is not None + first.comicvine_id = 7 + pending = StoryArc(name="Not persisted", comicvine_id=8) + session.add(pending) + report = await audit_legacy_identities( + session, MetadataEntityKind.STORY_ARC, page_size=1, max_evidence_rows=1 + ) + assert not report.complete and report.has_blockers + assert report.stop_reason == IdentityAuditStopReason.EVIDENCE_LIMIT + assert report.targets_checked == report.evidence_rows_checked == 1 + assert report.problems[0].local_id == first.id + assert pending.id is None + await session.rollback() + async with factory() as session: + assert ( + await session.scalars(select(StoryArc.comicvine_id).order_by(StoryArc.id)) + ).all() == [0, 42] diff --git a/tests/unit/test_metadata_identity_audit.py b/tests/unit/test_metadata_identity_audit.py new file mode 100644 index 00000000..7f495f57 --- /dev/null +++ b/tests/unit/test_metadata_identity_audit.py @@ -0,0 +1,315 @@ +"""Whole-kind preflight must not confuse partial or normalized evidence with safety.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import event, select + +from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity +from pullbox.services import metadata_identity_audit as audit_module +from pullbox.services.metadata_identity_audit import ( + IdentityAuditStopReason, + audit_legacy_identities, +) +from pullbox.services.metadata_identity_inventory import LegacyIdentityStorage + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def _arcs(session: AsyncSession, values: list[int | None]) -> list[StoryArc]: + arcs = [StoryArc(name=f"Arc {index}", comicvine_id=value) for index, value in enumerate(values)] + session.add_all(arcs) + await session.commit() + return arcs + + +def _relation( + arc: StoryArc, value: str, source: str = "comicvine", namespace: str = "story_arc" +) -> StoryArcExternalIdentity: + return StoryArcExternalIdentity( + story_arc_id=arc.id, source=source, namespace=namespace, external_id=value + ) + + +@pytest.mark.parametrize("kind", list(MetadataEntityKind)) +async def test_audit_empty_kind_is_complete_without_invented_issues( + db_session: AsyncSession, kind: MetadataEntityKind +) -> None: + report = await audit_legacy_identities(db_session, kind) + assert report.entity_kind == kind + assert report.targets_checked == report.observations_checked == 0 + assert report.complete and not report.has_blockers + + +async def test_cross_page_normalized_collision_retains_all_claims_and_storage_locators( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [42, None, None]) + relations = [_relation(arcs[0], "00042"), _relation(arcs[2], " 42 ")] + db_session.add_all(relations) + await db_session.commit() + + small = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=1) + large = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=500) + + assert small == large + assert small.complete and small.has_blockers + assert small.targets_checked == 3 + assert small.observations_checked == 3 + assert len(small.collisions) == 1 + collision = small.collisions[0] + assert collision.identity.external_id == "42" + assert collision.identity.namespace == IdentityNamespace.COMICVINE + assert [ + (claim.local_id, claim.storage, claim.storage_row_id) for claim in collision.claims + ] == [ + (arcs[0].id, LegacyIdentityStorage.COMICVINE_COLUMN, arcs[0].id), + (arcs[0].id, LegacyIdentityStorage.STORY_ARC_RELATION, relations[0].id), + (arcs[2].id, LegacyIdentityStorage.STORY_ARC_RELATION, relations[1].id), + ] + assert small.duplicate_relations == small.disagreements == small.problems == () + assert arcs[0].comicvine_id == 42 + assert [row.external_id for row in relations] == ["00042", " 42 "] + + +async def test_agreement_is_not_collision_and_provider_namespaces_stay_independent( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [42, None]) + db_session.add_all([_relation(arcs[0], "00042"), _relation(arcs[1], "42", source="metron")]) + await db_session.commit() + report = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=1) + assert report.targets_checked == 2 + assert report.observations_checked == 3 + assert report.complete and not report.has_blockers + + +async def test_equivalent_duplicate_relations_need_explicit_consolidation_not_an_owner_winner( + db_session: AsyncSession, +) -> None: + [arc] = await _arcs(db_session, [42]) + relations = [_relation(arc, "42"), _relation(arc, "00042")] + db_session.add_all(relations) + await db_session.commit() + report = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC) + assert report.complete and report.has_blockers + assert report.collisions == report.disagreements == () + assert len(report.duplicate_relations) == 1 + duplicate = report.duplicate_relations[0] + assert duplicate.local_id == arc.id + assert duplicate.identity.external_id == "42" + assert duplicate.storage_row_ids == tuple(row.id for row in relations) + assert len((await db_session.scalars(select(StoryArcExternalIdentity))).all()) == 2 + + +async def test_disagreement_and_invalid_values_survive_page_boundaries_without_raw_payloads( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [42, 0, None, None]) + raw = "https://example.invalid/?token=private-fixture-value" + relations = [_relation(arcs[0], "43"), _relation(arcs[3], raw)] + db_session.add_all(relations) + await db_session.commit() + report = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=1) + assert report.complete and report.has_blockers + assert report.targets_checked == 4 + assert report.observations_checked == 2 + assert [(p.local_id, p.storage_row_id, p.code) for p in report.problems] == [ + (arcs[1].id, arcs[1].id, "invalid_external_id"), + (arcs[3].id, relations[1].id, "invalid_external_id"), + ] + assert len(report.disagreements) == 1 + assert report.disagreements[0].local_id == arcs[0].id + assert "private-fixture-value" not in repr(report) + + +async def test_import_scopes_never_become_canonical_ownership( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [42, None]) + db_session.add_all( + [ + _relation(arcs[1], "42", source="mylar3", namespace="database-a"), + _relation(arcs[1], "42", namespace="import:private"), + _relation(arcs[1], "42", source="folder"), + ] + ) + await db_session.commit() + report = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=1) + assert report.observations_checked == 1 + assert report.targets_checked == 2 + assert report.complete and not report.has_blockers + + +async def test_target_budget_cannot_report_a_clear_audit_when_later_owner_is_unchecked( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [42, None, None]) + db_session.add(_relation(arcs[2], "42")) + await db_session.commit() + report = await audit_legacy_identities( + db_session, MetadataEntityKind.STORY_ARC, page_size=500, max_targets=2 + ) + assert report.targets_checked == 2 + assert report.collisions == () + assert not report.complete and report.has_blockers + assert report.stop_reason == IdentityAuditStopReason.TARGET_LIMIT + + +async def test_exact_target_and_evidence_limits_can_finish_without_false_incompleteness( + db_session: AsyncSession, +) -> None: + await _arcs(db_session, [42, None, 43]) + report = await audit_legacy_identities( + db_session, MetadataEntityKind.STORY_ARC, page_size=2, max_targets=3, max_evidence_rows=2 + ) + assert report.targets_checked == 3 + assert report.observations_checked == 2 + assert report.complete and not report.has_blockers + + +async def test_evidence_budget_keeps_prior_findings_but_does_not_count_partial_pages( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [0, 42, 43]) + report = await audit_legacy_identities( + db_session, MetadataEntityKind.STORY_ARC, page_size=1, max_evidence_rows=2 + ) + assert not report.complete and report.has_blockers + assert report.stop_reason == IdentityAuditStopReason.EVIDENCE_LIMIT + assert report.targets_checked == 2 + assert report.observations_checked == 1 + assert report.evidence_rows_checked == 2 + assert report.problems[0].local_id == arcs[0].id + + +async def test_invalid_evidence_uses_the_same_retained_memory_budget( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [0, None, None]) + db_session.add_all([_relation(arcs[1], "bad-1"), _relation(arcs[1], "bad-2")]) + await db_session.commit() + report = await audit_legacy_identities( + db_session, MetadataEntityKind.STORY_ARC, page_size=1, max_evidence_rows=1 + ) + assert not report.complete and report.has_blockers + assert report.stop_reason == IdentityAuditStopReason.EVIDENCE_LIMIT + assert report.targets_checked == 1 + assert len(report.problems) == 1 + assert report.evidence_rows_checked == 1 + + +async def test_relation_overflow_is_explicit_incomplete_and_keeps_prior_findings( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, [0, None]) + db_session.add_all([_relation(arcs[1], str(index + 1)) for index in range(2001)]) + await db_session.commit() + report = await audit_legacy_identities(db_session, MetadataEntityKind.STORY_ARC, page_size=1) + assert not report.complete and report.has_blockers + assert report.stop_reason == IdentityAuditStopReason.PAGE_EVIDENCE_LIMIT + assert report.targets_checked == 1 + assert report.problems[0].local_id == arcs[0].id + + +async def test_audit_does_not_flush_commit_hydrate_or_per_target_query( + db_session: AsyncSession, +) -> None: + arcs = await _arcs(db_session, list(range(100, 125))) + arcs[0].comicvine_id = 900 + pending = StoryArc(name="Pending", comicvine_id=999) + db_session.add(pending) + before_map = set(db_session.identity_map) + statements = [] + + def record( + _conn: object, + _cursor: object, + statement: str, + _params: object, + _context: object, + _many: bool, + ) -> None: + statements.append(statement) + + event.listen(db_session.bind.sync_engine, "before_cursor_execute", record) + try: + report = await audit_legacy_identities( + db_session, MetadataEntityKind.STORY_ARC, page_size=10 + ) + finally: + event.remove(db_session.bind.sync_engine, "before_cursor_execute", record) + assert report.targets_checked == report.observations_checked == 25 + assert len(statements) == 6 + assert all(sql.lstrip().upper().startswith("SELECT") for sql in statements) + assert set(db_session.identity_map) == before_map + assert pending.id is None + await db_session.rollback() + assert await db_session.scalar(select(StoryArc.comicvine_id).order_by(StoryArc.id)) == 100 + + +@pytest.mark.parametrize("kind", [MetadataEntityKind.SERIES, MetadataEntityKind.ISSUE]) +async def test_series_and_issue_audits_do_not_mix_local_entity_id_spaces( + db_session: AsyncSession, kind: MetadataEntityKind +) -> None: + series = Series(title="Parent", sort_title="parent", comicvine_id=42) + db_session.add(series) + await db_session.flush() + db_session.add_all( + [ + Issue(series_id=series.id, issue_number=i, comicvine_id=value) + for i, value in enumerate([42, None, 43], 1) + ] + ) + await db_session.commit() + report = await audit_legacy_identities(db_session, kind, page_size=1) + assert report.targets_checked == (1 if kind == MetadataEntityKind.SERIES else 3) + assert report.observations_checked == (1 if kind == MetadataEntityKind.SERIES else 2) + assert report.complete and not report.has_blockers + + +@pytest.mark.parametrize( + "kwargs", + [ + {"page_size": 0}, + {"page_size": 501}, + {"page_size": True}, + {"page_size": "1"}, + {"max_targets": 0}, + {"max_targets": 1_000_001}, + {"max_targets": True}, + {"max_evidence_rows": 0}, + {"max_evidence_rows": 2_000_001}, + {"max_evidence_rows": 1.5}, + ], +) +async def test_invalid_bounds_fail_before_querying(kwargs: dict[str, object]) -> None: + session = AsyncMock() + with pytest.raises(ValueError, match="bounds"): + await audit_legacy_identities(session, MetadataEntityKind.SERIES, **kwargs) + session.execute.assert_not_called() + + +async def test_raw_unknown_kind_is_rejected_before_querying() -> None: + session = AsyncMock() + with pytest.raises(ValueError, match="entity kind"): + await audit_legacy_identities(session, cast("MetadataEntityKind", "series")) + session.execute.assert_not_called() + + +@pytest.mark.parametrize("error", [RuntimeError("read failed"), asyncio.CancelledError()]) +async def test_query_errors_and_cancellation_never_become_a_complete_report( + error: BaseException, +) -> None: + session = AsyncMock() + session.execute.side_effect = error + with pytest.raises(type(error)): + await audit_module.audit_legacy_identities(session, MetadataEntityKind.SERIES) From 17be908cde4222143b73e539e5cf668af8e52b09 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 18:05:12 -0700 Subject: [PATCH 06/97] feat: define metadata evidence provenance and retry contracts --- src/pullbox/core/metadata_identity_events.py | 236 ++++++++++++ tests/fixtures/metadata_identity_events.py | 122 +++++++ .../fixtures/metadata_identity_persistence.py | 7 + .../test_event_replay_contract.py | 219 +++++++++++ .../test_evidence_history_contract.py | 12 + tests/unit/test_metadata_identity_events.py | 340 ++++++++++++++++++ 6 files changed, 936 insertions(+) create mode 100644 src/pullbox/core/metadata_identity_events.py create mode 100644 tests/fixtures/metadata_identity_events.py create mode 100644 tests/integration/metadata_identity/test_event_replay_contract.py create mode 100644 tests/unit/test_metadata_identity_events.py diff --git a/src/pullbox/core/metadata_identity_events.py b/src/pullbox/core/metadata_identity_events.py new file mode 100644 index 00000000..540e0691 --- /dev/null +++ b/src/pullbox/core/metadata_identity_events.py @@ -0,0 +1,236 @@ +"""Validated identity evidence and semantic retry contracts, not authorization.""" + +from __future__ import annotations + +import enum +import hashlib +import json +from dataclasses import dataclass +from uuid import UUID + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, +) +from pullbox.core.metadata_identity_state import IdentityVerificationAction + + +class IdentityEvidenceRecordKind(enum.StrEnum): + SERIES = "series" + ISSUE = "issue" + STORY_ARC = "story_arc" + STORY_ARC_IDENTITY = "story_arc_identity" + IMPORTED_SERIES = "imported_series" + IMPORTED_FILE = "imported_file" + LIBRARY_FILE = "library_file" + + +class IdentityEventActor(enum.StrEnum): + AUTOMATION = "automation" + USER = "user" + MIGRATION = "migration" + + +@dataclass(frozen=True) +class IdentityEvidenceLocator: + """Safe local record locator, not a path or evidence payload.""" + + record_kind: IdentityEvidenceRecordKind + record_id: int + + def __post_init__(self) -> None: + if not isinstance(self.record_kind, IdentityEvidenceRecordKind) or not _positive_id( + self.record_id + ): + raise ValueError("Invalid identity evidence record locator") + + +@dataclass(frozen=True) +class IdentityEventEvidence: + """One revision of local or provider evidence; it does not establish trust.""" + + claim: ExactIdentityEvidence + revision: str + locator: IdentityEvidenceLocator | None = None + source_identity: ExternalIdentityRef | None = None + + def __post_init__(self) -> None: + if not isinstance(self.claim, ExactIdentityEvidence) or not isinstance( + self.claim.identity, ExternalIdentityRef + ): + raise ValueError("Invalid identity evidence claim") + if not _is_digest(self.revision): + raise ValueError("Invalid identity evidence revision digest") + provider_kinds = { + IdentityEvidenceKind.PROVIDER_RESULT, + IdentityEvidenceKind.PROVIDER_CROSSWALK, + } + source = self.claim.source_instance + if source is None: + if ( + not isinstance(self.locator, IdentityEvidenceLocator) + or self.source_identity is not None + or self.claim.evidence_kind in provider_kinds + ): + raise ValueError("Invalid local identity evidence provenance") + return + if ( + self.locator is not None + or not isinstance(self.source_identity, ExternalIdentityRef) + or self.source_identity.namespace != source.identity_namespace + or self.source_identity.entity_kind != self.claim.identity.entity_kind + or self.claim.evidence_kind + not in provider_kinds | {IdentityEvidenceKind.USER_SELECTION} + ): + raise ValueError("Invalid provider identity evidence provenance") + if self.claim.evidence_kind is IdentityEvidenceKind.PROVIDER_CROSSWALK: + if self.claim.identity.namespace == self.source_identity.namespace: + raise ValueError("A crosswalk must identify another provider namespace") + elif self.source_identity != self.claim.identity: + raise ValueError("Provider result identity disagrees with its source record") + + +@dataclass(frozen=True) +class IdentityEventRequest: + """Server-assembled logical action; typed actors/UUIDs do not authorize it. + + Keep operation_id durable across retries. A new observation run or explicit + decision receives a new server-generated operation ID. Source adapters bind + revision to the evidence snapshot. Auth, review freshness, exact agreement, + and ownership checks still belong to the application transaction. + """ + + operation_id: UUID + local_id: int + action: IdentityVerificationAction + evidence: IdentityEventEvidence + actor: IdentityEventActor = IdentityEventActor.AUTOMATION + actor_user_id: int | None = None + review_revision: int | None = None + + def __post_init__(self) -> None: + if not isinstance(self.operation_id, UUID) or not self.operation_id.int: + raise ValueError("Invalid identity operation ID") + if not _positive_id(self.local_id) or not isinstance( + self.action, IdentityVerificationAction + ): + raise ValueError("Invalid identity event target or action") + if not isinstance(self.evidence, IdentityEventEvidence): + raise ValueError("Invalid identity event evidence") + if not isinstance(self.actor, IdentityEventActor): + raise ValueError("Invalid identity event actor") + if self.actor is IdentityEventActor.USER: + if not _positive_id(self.actor_user_id): + raise ValueError("User identity event actor requires a user ID") + elif self.actor_user_id is not None: + raise ValueError("Non-user identity event actor cannot supply a user ID") + if self.review_revision is not None and not _positive_id(self.review_revision): + raise ValueError("Invalid identity review revision") + if ( + self.action in {IdentityVerificationAction.CONFIRM, IdentityVerificationAction.REJECT} + or self.evidence.claim.evidence_kind is IdentityEvidenceKind.USER_SELECTION + ) and (self.actor is not IdentityEventActor.USER or self.review_revision is None): + raise ValueError("Explicit identity review requires a user and review revision") + + +@dataclass(frozen=True) +class PreparedIdentityEvent: + event_key: str + request_fingerprint: str + request_json: str + + +class IdentityEventReplayConflictError(ValueError): + """A stored operation cannot be reinterpreted as a different request.""" + + +def prepare_identity_event(request: IdentityEventRequest) -> PreparedIdentityEvent: + """Build a stable retry slot and separate semantic request fingerprint.""" + if not isinstance(request, IdentityEventRequest): + raise ValueError("Invalid identity event request") + evidence = request.evidence + identity = evidence.claim.identity + origin: dict[str, object] + if evidence.locator is not None: + origin = { + "record_kind": evidence.locator.record_kind.value, + "record_id": evidence.locator.record_id, + } + else: + assert evidence.claim.source_instance is not None and evidence.source_identity is not None + origin = { + "source_instance": evidence.claim.source_instance.value, + "source_identity": _identity_payload(evidence.source_identity), + } + slot = { + "version": 1, + "operation_id": str(request.operation_id), + "entity_kind": identity.entity_kind.value, + "local_id": request.local_id, + "identity_namespace": identity.namespace.value, + "action": request.action.value, + "origin": origin, + "evidence_revision": evidence.revision, + } + payload = { + "version": 1, + "operation_id": str(request.operation_id), + "local_id": request.local_id, + "identity": _identity_payload(identity), + "action": request.action.value, + "evidence_kind": evidence.claim.evidence_kind.value, + "evidence_revision": evidence.revision, + "origin": origin, + "actor": request.actor.value, + "actor_user_id": request.actor_user_id, + "review_revision": request.review_revision, + } + serialized = _canonical_json(payload) + return PreparedIdentityEvent(_digest(_canonical_json(slot)), _digest(serialized), serialized) + + +def validate_identity_event_replay( + request: PreparedIdentityEvent, *, event_key: str, request_fingerprint: str +) -> None: + """Reject a reused retry slot with changed semantics; never update history.""" + if ( + not _is_digest(event_key) + or not _is_digest(request_fingerprint) + or not _is_digest(request.event_key) + or not _is_digest(request.request_fingerprint) + or request.event_key != event_key + or request.request_fingerprint != request_fingerprint + ): + raise IdentityEventReplayConflictError( + "Identity operation was already recorded with a different request" + ) + + +def _positive_id(value: object) -> bool: + return type(value) is int and 0 < value < 2**63 + + +def _is_digest(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(char in "0123456789abcdef" for char in value) + ) + + +def _identity_payload(identity: ExternalIdentityRef) -> dict[str, str]: + return { + "namespace": identity.namespace.value, + "entity_kind": identity.entity_kind.value, + "external_id": identity.external_id, + } + + +def _canonical_json(value: dict[str, object]) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _digest(value: str) -> str: + # Content/retry identifiers only; not credentials or authorization tokens. + return hashlib.sha256(value.encode("ascii")).hexdigest() diff --git a/tests/fixtures/metadata_identity_events.py b/tests/fixtures/metadata_identity_events.py new file mode 100644 index 00000000..1cb12718 --- /dev/null +++ b/tests/fixtures/metadata_identity_events.py @@ -0,0 +1,122 @@ +"""Disposable event persistence rehearsal; not a runtime attachment service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventEvidence, + IdentityEventReplayConflictError, + IdentityEventRequest, + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + prepare_identity_event, + validate_identity_event_replay, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationAction, + IdentityVerificationState, +) + +if TYPE_CHECKING: + from datetime import datetime + + from sqlalchemy import Table + from sqlalchemy.ext.asyncio import AsyncSession + +_DEFAULT_OPERATION_ID = UUID(int=1) + + +def identity_event_request( + kind: MetadataEntityKind, + local_id: int, + action: IdentityVerificationAction = IdentityVerificationAction.OBSERVE, + *, + external_id: str = "100", + operation_id: UUID = _DEFAULT_OPERATION_ID, +) -> IdentityEventRequest: + review = action in {IdentityVerificationAction.REJECT, IdentityVerificationAction.CONFIRM} + return IdentityEventRequest( + operation_id, + local_id, + action, + IdentityEventEvidence( + ExactIdentityEvidence( + ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, external_id), + IdentityEvidenceKind.COMICINFO_XML, + ), + "a" * 64, + IdentityEvidenceLocator(IdentityEvidenceRecordKind.IMPORTED_FILE, 5), + ), + actor=IdentityEventActor.USER if review else IdentityEventActor.AUTOMATION, + actor_user_id=1 if review else None, + review_revision=1 if review else None, + ) + + +@dataclass(frozen=True) +class IdentityEventReceipt: + id: int + state: IdentityVerificationState + created_at: datetime + request_json: str + + +async def record_identity_event_probe( + session: AsyncSession, + table: Table, + request: IdentityEventRequest, + state: IdentityVerificationState, +) -> IdentityEventReceipt: + """Rehearse atomic append/replay only; caller owns transaction and authorization.""" + prepared = prepare_identity_event(request) + claim = request.evidence.claim + identity = claim.identity + target = table.c[f"{identity.entity_kind.value}_id"] + backend = session.get_bind().dialect.name + if backend == "postgresql": + statement = pg_insert(table) + elif backend == "sqlite": + statement = sqlite_insert(table) + else: + raise ValueError("Unsupported identity rehearsal database") + # Attempt the unique write first so concurrent retries have one durable result. + await session.execute( + statement.values( + { + target.name: request.local_id, + "identity_namespace": identity.namespace.value, + "external_id": identity.external_id, + "event_key": prepared.event_key, + "request_fingerprint": prepared.request_fingerprint, + "request_json": prepared.request_json, + "verification_state": state.value, + "evidence_kind": claim.evidence_kind.value, + } + ).on_conflict_do_nothing(index_elements=[target, table.c.event_key]) + ) + row = ( + await session.execute( + select(table).where(target == request.local_id, table.c.event_key == prepared.event_key) + ) + ).one() + validate_identity_event_replay( + prepared, event_key=row.event_key, request_fingerprint=row.request_fingerprint + ) + if row.request_json != prepared.request_json: + raise IdentityEventReplayConflictError("Stored identity request payload is inconsistent") + return IdentityEventReceipt(row.id, row.verification_state, row.created_at, row.request_json) diff --git a/tests/fixtures/metadata_identity_persistence.py b/tests/fixtures/metadata_identity_persistence.py index 66bcd58f..e3f7a0ef 100644 --- a/tests/fixtures/metadata_identity_persistence.py +++ b/tests/fixtures/metadata_identity_persistence.py @@ -14,6 +14,7 @@ MetaData, String, Table, + Text, UniqueConstraint, ) from sqlalchemy import Enum as SQLAlchemyEnum @@ -110,6 +111,12 @@ def build_identity_schema_probe() -> IdentitySchemaProbe: Column("identity_namespace", String(50), nullable=False), Column("external_id", String(255), nullable=False), Column("event_key", String(64), nullable=False), + Column("request_fingerprint", String(64), nullable=False), + Column("request_json", Text, nullable=False), + CheckConstraint( + "length(request_fingerprint) = 64", + name=f"mp0_{kind.value}_event_fingerprint_length", + ), Column( "verification_state", _stored_enum(IdentityVerificationState, f"mp0_{kind.value}_event_state"), diff --git a/tests/integration/metadata_identity/test_event_replay_contract.py b/tests/integration/metadata_identity/test_event_replay_contract.py new file mode 100644 index 00000000..40aa1e0b --- /dev/null +++ b/tests/integration/metadata_identity/test_event_replay_contract.py @@ -0,0 +1,219 @@ +"""Durable retries return the original result instead of reinterpreting history.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from sqlalchemy import func, insert, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity_events import ( + IdentityEventReplayConflictError, + prepare_identity_event, +) +from pullbox.core.metadata_identity_state import IdentityVerificationAction as Action +from pullbox.core.metadata_identity_state import IdentityVerificationState as State +from pullbox.models.issue import Issue +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc +from tests.fixtures.metadata_identity_events import ( + identity_event_request, + record_identity_event_probe, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from tests.fixtures.metadata_identity_events import IdentityEventReceipt + from tests.fixtures.metadata_identity_persistence import IdentityProbeDatabase + + +async def _target(factory: async_sessionmaker[AsyncSession], kind: Kind) -> int: + async with factory.begin() as session: + series = Series(title="Evidence", sort_title="evidence") + session.add(series) + await session.flush() + if kind is Kind.SERIES: + return series.id + target = ( + Issue(series_id=series.id, issue_number=1) + if kind is Kind.ISSUE + else StoryArc(name="Evidence") + ) + session.add(target) + await session.flush() + return target.id + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_retry_returns_original_receipt_without_appending_or_recomputing_result( + identity_probe_db: IdentityProbeDatabase, + kind: Kind, +) -> None: + _engine, factory, probe = identity_probe_db + target = await _target(factory, kind) + request = identity_event_request(kind, target) + table = probe.events[kind] + async with factory.begin() as session: + first = await record_identity_event_probe(session, table, request, State.OBSERVED) + async with factory.begin() as session: + second = await record_identity_event_probe(session, table, request, State.VERIFIED) + assert first == second + assert first.id > 0 and first.state is State.OBSERVED + assert json.loads(first.request_json)["origin"] == { + "record_kind": "imported_file", + "record_id": 5, + } + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(table)) == 1 + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_semantically_different_retry_cannot_overwrite_original_record( + identity_probe_db: IdentityProbeDatabase, + kind: Kind, +) -> None: + _engine, factory, probe = identity_probe_db + target = await _target(factory, kind) + request = identity_event_request(kind, target) + changed = replace( + request, + evidence=replace( + request.evidence, + claim=replace( + request.evidence.claim, + identity=replace(request.evidence.claim.identity, external_id="101"), + ), + ), + ) + table = probe.events[kind] + async with factory.begin() as session: + first = await record_identity_event_probe(session, table, request, State.OBSERVED) + with pytest.raises(IdentityEventReplayConflictError): + async with factory.begin() as session: + await record_identity_event_probe(session, table, changed, State.VERIFIED) + async with factory() as session: + rows = (await session.execute(select(table))).all() + assert len(rows) == 1 + assert rows[0].request_json == first.request_json + assert rows[0].external_id == "100" + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_later_review_preserves_prior_rejection_as_a_separate_event( + identity_probe_db: IdentityProbeDatabase, + kind: Kind, +) -> None: + _engine, factory, probe = identity_probe_db + target = await _target(factory, kind) + table = probe.events[kind] + rejected = identity_event_request(kind, target, Action.REJECT) + confirmed = replace( + identity_event_request(kind, target, Action.CONFIRM, operation_id=UUID(int=2)), + review_revision=2, + ) + async with factory.begin() as session: + first = await record_identity_event_probe(session, table, rejected, State.REJECTED) + second = await record_identity_event_probe(session, table, confirmed, State.VERIFIED) + assert first.id != second.id + async with factory() as session: + rows = (await session.execute(select(table).order_by(table.c.id))).all() + assert [row.verification_state for row in rows] == [State.REJECTED, State.VERIFIED] + assert [json.loads(row.request_json)["review_revision"] for row in rows] == [1, 2] + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_concurrent_identical_retries_have_one_durable_receipt( + identity_probe_db: IdentityProbeDatabase, + kind: Kind, +) -> None: + _engine, factory, probe = identity_probe_db + target = await _target(factory, kind) + request = identity_event_request(kind, target) + table = probe.events[kind] + barrier = asyncio.Barrier(2) + + async def record(state: State) -> IdentityEventReceipt: + await barrier.wait() + async with factory.begin() as session: + return await record_identity_event_probe(session, table, request, state) + + first, second = await asyncio.wait_for( + asyncio.gather(record(State.OBSERVED), record(State.VERIFIED)), timeout=15 + ) + assert first == second and first.id > 0 + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(table)) == 1 + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_recording_does_not_commit_or_outlive_the_caller_transaction( + identity_probe_db: IdentityProbeDatabase, + kind: Kind, +) -> None: + _engine, factory, probe = identity_probe_db + target = await _target(factory, kind) + table = probe.events[kind] + with pytest.raises(RuntimeError, match="caller rollback"): + async with factory.begin() as session: + await record_identity_event_probe( + session, table, identity_event_request(kind, target), State.OBSERVED + ) + assert await session.scalar(select(func.count()).select_from(table)) == 1 + raise RuntimeError("caller rollback") + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(table)) == 0 + + +@pytest.mark.parametrize( + "column,value", + [("request_fingerprint", None), ("request_fingerprint", "short"), ("request_json", None)], +) +async def test_persisted_events_require_replay_material( + identity_probe_db: IdentityProbeDatabase, column: str, value: str | None +) -> None: + _engine, factory, probe = identity_probe_db + kind = Kind.SERIES + target = await _target(factory, kind) + request = identity_event_request(kind, target) + prepared = prepare_identity_event(request) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute( + insert(probe.events[kind]) + .values( + series_id=target, + identity_namespace="comicvine", + external_id="100", + event_key=prepared.event_key, + request_fingerprint=prepared.request_fingerprint, + request_json=prepared.request_json, + verification_state="observed", + evidence_kind="comicinfo_xml", + ) + .values(**{column: value}) + ) + + +async def test_inconsistent_stored_payload_does_not_get_silently_replayed( + identity_probe_db: IdentityProbeDatabase, +) -> None: + _engine, factory, probe = identity_probe_db + kind = Kind.SERIES + target = await _target(factory, kind) + table = probe.events[kind] + request = identity_event_request(kind, target) + async with factory.begin() as session: + receipt = await record_identity_event_probe(session, table, request, State.OBSERVED) + await session.execute( + update(table).where(table.c.id == receipt.id).values(request_json="{}") + ) + with pytest.raises(IdentityEventReplayConflictError): + async with factory.begin() as session: + await record_identity_event_probe(session, table, request, State.OBSERVED) diff --git a/tests/integration/metadata_identity/test_evidence_history_contract.py b/tests/integration/metadata_identity/test_evidence_history_contract.py index 5defa102..5c2d7703 100644 --- a/tests/integration/metadata_identity/test_evidence_history_contract.py +++ b/tests/integration/metadata_identity/test_evidence_history_contract.py @@ -10,10 +10,13 @@ from sqlalchemy.exc import IntegrityError from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity_events import prepare_identity_event +from pullbox.core.metadata_identity_state import IdentityVerificationAction as Action from pullbox.core.metadata_identity_state import IdentityVerificationState as State from pullbox.models.issue import Issue from pullbox.models.series import Series from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity +from tests.fixtures.metadata_identity_events import identity_event_request if TYPE_CHECKING: from sqlalchemy import Insert, Table @@ -53,11 +56,20 @@ def _event( key: str = "a" * 64, external_id: str = "100", ) -> Insert: + request = identity_event_request( + kind, + target, + Action.REJECT if state is State.REJECTED else Action.OBSERVE, + external_id=external_id, + ) + prepared = prepare_identity_event(request) return insert(table).values( **{f"{kind.value}_id": target}, identity_namespace="comicvine", external_id=external_id, event_key=key, + request_fingerprint=prepared.request_fingerprint, + request_json=prepared.request_json, verification_state=state.value, evidence_kind="user_selection" if state is State.REJECTED else "comicinfo_xml", ) diff --git a/tests/unit/test_metadata_identity_events.py b/tests/unit/test_metadata_identity_events.py new file mode 100644 index 00000000..a2c6a924 --- /dev/null +++ b/tests/unit/test_metadata_identity_events.py @@ -0,0 +1,340 @@ +"""Identity retries retain provenance and cannot rewrite a previous decision.""" + +from __future__ import annotations + +import json +from dataclasses import FrozenInstanceError, replace +from uuid import UUID + +import pytest + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, +) +from pullbox.core.metadata_identity import ( + IdentityEvidenceKind as Evidence, +) +from pullbox.core.metadata_identity import ( + IdentityNamespace as Namespace, +) +from pullbox.core.metadata_identity import ( + MetadataEntityKind as Kind, +) +from pullbox.core.metadata_identity import ( + MetadataSource as Source, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor as Actor, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventEvidence, + IdentityEventReplayConflictError, + IdentityEventRequest, + IdentityEvidenceLocator, + prepare_identity_event, + validate_identity_event_replay, +) +from pullbox.core.metadata_identity_events import ( + IdentityEvidenceRecordKind as Record, +) +from pullbox.core.metadata_identity_state import IdentityVerificationAction as Action + + +def _identity(value: str = "42", namespace: Namespace = Namespace.COMICVINE) -> ExternalIdentityRef: + return ExternalIdentityRef(namespace, Kind.ISSUE, value) + + +def _request() -> IdentityEventRequest: + return IdentityEventRequest( + UUID("e614d9ad-354b-4dbc-b7dd-3f07ca140c56"), + 7, + Action.OBSERVE, + IdentityEventEvidence( + ExactIdentityEvidence(_identity(), Evidence.COMICINFO_XML), + "a" * 64, + IdentityEvidenceLocator(Record.IMPORTED_FILE, 123), + ), + ) + + +def test_retry_payload_has_explicit_version_and_only_safe_provenance_fields() -> None: + prepared = prepare_identity_event(_request()) + payload = json.loads(prepared.request_json) + assert payload == { + "version": 1, + "operation_id": "e614d9ad-354b-4dbc-b7dd-3f07ca140c56", + "local_id": 7, + "identity": {"namespace": "comicvine", "entity_kind": "issue", "external_id": "42"}, + "action": "observe", + "evidence_kind": "comicinfo_xml", + "evidence_revision": "a" * 64, + "origin": {"record_kind": "imported_file", "record_id": 123}, + "actor": "automation", + "actor_user_id": None, + "review_revision": None, + } + assert len(prepared.event_key) == len(prepared.request_fingerprint) == 64 + assert prepared.event_key != prepared.request_fingerprint + + +def test_normalized_id_and_rebuilt_request_have_identical_retry_material() -> None: + request = _request() + normalized = replace( + request, + evidence=replace( + request.evidence, claim=replace(request.evidence.claim, identity=_identity(" 00042 ")) + ), + ) + first = prepare_identity_event(request) + assert first == prepare_identity_event(normalized) == prepare_identity_event(_request()) + assert first.event_key + validate_identity_event_replay( + first, event_key=first.event_key, request_fingerprint=first.request_fingerprint + ) + + +@pytest.mark.parametrize("change", ["identity", "evidence_kind", "actor", "review_revision"]) +def test_reused_retry_slot_with_different_semantics_fails_without_echoing_payload( + change: str, +) -> None: + request = replace(_request(), actor=Actor.USER, actor_user_id=1, review_revision=2) + changed = { + "identity": replace( + request, + evidence=replace( + request.evidence, claim=replace(request.evidence.claim, identity=_identity("43")) + ), + ), + "evidence_kind": replace( + request, + evidence=replace( + request.evidence, + claim=replace(request.evidence.claim, evidence_kind=Evidence.METRONINFO_XML), + ), + ), + "actor": replace(request, actor_user_id=2), + "review_revision": replace(request, review_revision=3), + }[change] + stored, replay = prepare_identity_event(request), prepare_identity_event(changed) + assert stored.event_key == replay.event_key + assert stored.request_fingerprint != replay.request_fingerprint + with pytest.raises(IdentityEventReplayConflictError, match="different request") as error: + validate_identity_event_replay( + replay, event_key=stored.event_key, request_fingerprint=stored.request_fingerprint + ) + assert replay.request_json not in str(error.value) + + +@pytest.mark.parametrize( + "change", ["operation", "target", "action", "revision", "locator", "namespace"] +) +def test_new_logical_evidence_or_decision_gets_a_new_retry_slot(change: str) -> None: + request = _request() + changed = { + "operation": replace(request, operation_id=UUID(int=2)), + "target": replace(request, local_id=8), + "action": replace(request, action=Action.REPORT_CONFLICT), + "revision": replace(request, evidence=replace(request.evidence, revision="b" * 64)), + "locator": replace( + request, + evidence=replace( + request.evidence, locator=IdentityEvidenceLocator(Record.IMPORTED_FILE, 124) + ), + ), + "namespace": replace( + request, + evidence=replace( + request.evidence, + claim=replace( + request.evidence.claim, identity=_identity(namespace=Namespace.METRON) + ), + ), + ), + }[change] + assert prepare_identity_event(request).event_key != prepare_identity_event(changed).event_key + + +def test_provider_transport_and_crosswalk_asserting_identity_are_retained() -> None: + claim = ExactIdentityEvidence(_identity(), Evidence.PROVIDER_CROSSWALK, Source.METRON_API) + source_identity = _identity("75", Namespace.METRON) + request = replace( + _request(), evidence=IdentityEventEvidence(claim, "a" * 64, source_identity=source_identity) + ) + origin = json.loads(prepare_identity_event(request).request_json)["origin"] + assert origin == { + "source_instance": "metron_api", + "source_identity": {"namespace": "metron", "entity_kind": "issue", "external_id": "75"}, + } + + +def test_local_and_api_sources_share_identity_but_not_observation_provenance() -> None: + request = _request() + claims = [ + ExactIdentityEvidence(_identity(), Evidence.PROVIDER_RESULT, source) + for source in (Source.COMICVINE_LOCAL, Source.COMICVINE_API) + ] + prepared = [ + prepare_identity_event( + replace( + request, + evidence=IdentityEventEvidence(claim, "a" * 64, source_identity=_identity()), + ) + ) + for claim in claims + ] + assert prepared[0].event_key != prepared[1].event_key + + +@pytest.mark.parametrize("action", [Action.CONFIRM, Action.REJECT]) +def test_explicit_decisions_require_a_user_and_review_revision(action: Action) -> None: + with pytest.raises(ValueError, match="review"): + replace(_request(), action=action) + with pytest.raises(ValueError, match="review"): + replace(_request(), action=action, actor=Actor.USER, actor_user_id=1) + request = replace( + _request(), action=action, actor=Actor.USER, actor_user_id=1, review_revision=2 + ) + assert json.loads(prepare_identity_event(request).request_json)["review_revision"] == 2 + + +@pytest.mark.parametrize( + "field,value", + [ + ("operation_id", "not-a-uuid"), + ("operation_id", UUID(int=0)), + ("local_id", True), + ("local_id", 0), + ("local_id", -1), + ("local_id", 2**63), + ("action", "confirm"), + ("actor", "user"), + ("actor_user_id", 1), + ("review_revision", True), + ("review_revision", 0), + ("evidence", {}), + ], +) +def test_invalid_request_fields_are_rejected(field: str, value: object) -> None: + with pytest.raises(ValueError): + replace(_request(), **{field: value}) + + +@pytest.mark.parametrize( + "record_kind,record_id", + [ + ("issue", 1), + (Record.IMPORTED_FILE, True), + (Record.IMPORTED_FILE, 0), + (Record.IMPORTED_FILE, "path"), + (Record.IMPORTED_FILE, 2**63), + ], +) +def test_locators_accept_only_typed_record_ids_not_paths( + record_kind: object, record_id: object +) -> None: + with pytest.raises(ValueError): + IdentityEvidenceLocator(record_kind, record_id) + + +@pytest.mark.parametrize( + "revision", + [ + "", + "a" * 63, + "a" * 65, + "G" * 64, + "A" * 64, + "https://example.invalid/?token=secret", + "private\npath", + ], +) +def test_evidence_revision_is_a_bounded_digest_not_raw_content(revision: str) -> None: + with pytest.raises(ValueError) as error: + replace(_request().evidence, revision=revision) + assert revision not in str(error.value) if revision else True + + +@pytest.mark.parametrize( + "scenario", + [ + "no_origin", + "both_origins", + "untyped_locator", + "provider_without_source", + "provider_wrong_namespace", + "provider_wrong_kind", + "result_disagrees", + "local_with_provider", + "crosswalk_to_same_namespace", + "bad_claim", + ], +) +def test_inconsistent_provenance_fails_before_serialization(scenario: str) -> None: + local = _request().evidence + provider = ExactIdentityEvidence(_identity(), Evidence.PROVIDER_RESULT, Source.COMICVINE_API) + with pytest.raises(ValueError): + if scenario == "no_origin": + replace(local, locator=None) + elif scenario == "both_origins": + replace(local, claim=provider, source_identity=_identity()) + elif scenario == "untyped_locator": + replace(local, locator={"path": "/private/source"}) + elif scenario == "provider_without_source": + replace(local, claim=ExactIdentityEvidence(_identity(), Evidence.PROVIDER_RESULT)) + elif scenario == "provider_wrong_namespace": + IdentityEventEvidence( + provider, "a" * 64, source_identity=_identity(namespace=Namespace.METRON) + ) + elif scenario == "provider_wrong_kind": + IdentityEventEvidence( + provider, + "a" * 64, + source_identity=ExternalIdentityRef(Namespace.COMICVINE, Kind.SERIES, "42"), + ) + elif scenario == "result_disagrees": + IdentityEventEvidence(provider, "a" * 64, source_identity=_identity("43")) + elif scenario == "local_with_provider": + IdentityEventEvidence( + replace(provider, evidence_kind=Evidence.COMICINFO_XML), + "a" * 64, + source_identity=_identity(), + ) + elif scenario == "crosswalk_to_same_namespace": + IdentityEventEvidence( + replace(provider, evidence_kind=Evidence.PROVIDER_CROSSWALK), + "a" * 64, + source_identity=_identity(), + ) + else: + replace(local, claim={}) + + +def test_user_actor_requires_user_id_and_user_selection_requires_review_context() -> None: + with pytest.raises(ValueError, match="actor"): + replace(_request(), actor=Actor.USER) + with pytest.raises(ValueError, match="review"): + replace( + _request(), + evidence=replace( + _request().evidence, + claim=replace(_request().evidence.claim, evidence_kind=Evidence.USER_SELECTION), + ), + ) + + +def test_request_and_prepared_material_are_immutable() -> None: + request = _request() + with pytest.raises(FrozenInstanceError): + request.local_id = 8 + prepared = prepare_identity_event(request) + with pytest.raises(FrozenInstanceError): + prepared.request_fingerprint = "b" * 64 + + +@pytest.mark.parametrize("key,digest", [("bad", "a" * 64), ("a" * 64, "bad"), ("a" * 64, "b" * 64)]) +def test_unknown_or_different_persisted_retry_material_fails_closed(key: str, digest: str) -> None: + with pytest.raises(IdentityEventReplayConflictError): + validate_identity_event_replay( + prepare_identity_event(_request()), event_key=key, request_fingerprint=digest + ) From e618837176d80a9ba0f485bb77a93e7db52e010e Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 18:41:18 -0700 Subject: [PATCH 07/97] feat: add bounded MetronInfo identity evidence reader --- src/pullbox/core/metroninfo.py | 404 +++++++++++++++++++++++++++++ tests/fixtures/metroninfo/v1_0.xml | 13 + tests/fixtures/metroninfo/v1_1.xml | 12 + tests/unit/test_metroninfo.py | 382 +++++++++++++++++++++++++++ 4 files changed, 811 insertions(+) create mode 100644 src/pullbox/core/metroninfo.py create mode 100644 tests/fixtures/metroninfo/v1_0.xml create mode 100644 tests/fixtures/metroninfo/v1_1.xml create mode 100644 tests/unit/test_metroninfo.py diff --git a/src/pullbox/core/metroninfo.py b/src/pullbox/core/metroninfo.py new file mode 100644 index 00000000..a21fdbc6 --- /dev/null +++ b/src/pullbox/core/metroninfo.py @@ -0,0 +1,404 @@ +"""Bounded, read-only MetronInfo evidence; never a matching or write decision.""" + +from __future__ import annotations + +import enum +import io +import re +from dataclasses import dataclass, replace +from datetime import date +from xml.etree import ElementTree + +from defusedxml import ElementTree as DefusedElementTree +from defusedxml.common import DefusedXmlException + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + find_exact_identity_conflicts, +) +from pullbox.core.xml_security import UnsafeXmlError, normalize_xml_for_expat + +MAX_METRONINFO_BYTES = 2 * 1024 * 1024 +MAX_METRONINFO_DEPTH = 32 +MAX_METRONINFO_NODES = 4096 +_MAX_ATTRIBUTES = 16 +_MAX_FIELD_LENGTH = 4096 +_CALENDAR_TIMEZONE = r"(?:Z|[+-](?:(?:0[0-9]|1[0-3]):[0-5][0-9]|14:00))?" +_XINCLUDE = "{http://www.w3.org/2001/XInclude}" +_SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation" +_SOURCES = { + "metron": IdentityNamespace.METRON, + "comic vine": IdentityNamespace.COMICVINE, + "comicvine": IdentityNamespace.COMICVINE, + "grand comics database": IdentityNamespace.GCD, + "gcd": IdentityNamespace.GCD, + "league of comic geeks": IdentityNamespace.LOCG, + "locg": IdentityNamespace.LOCG, +} + + +class MetronInfoDiagnosticCode(enum.StrEnum): + INVALID_XML = "invalid_xml" + UNSAFE_XML = "unsafe_xml" + TOO_LARGE = "too_large" + COMPLEXITY_LIMIT = "complexity_limit" + INVALID_ROOT = "invalid_root" + UNSUPPORTED_VERSION = "unsupported_version" + UNSUPPORTED_ENCODING = "unsupported_encoding" + INVALID_ID = "invalid_id" + UNKNOWN_SOURCE = "unknown_source" + INVALID_PRIMARY = "invalid_primary" + MISSING_PRIMARY = "missing_primary" + AMBIGUOUS_PRIMARY = "ambiguous_primary" + INVALID_FIELD = "invalid_field" + AMBIGUOUS_FIELD = "ambiguous_field" + EXACT_ID_CONFLICT = "exact_id_conflict" + UNMAPPED_CONTENT = "unmapped_content" + + +@dataclass(frozen=True) +class MetronInfoDiagnostic: + code: MetronInfoDiagnosticCode + locator: str + + +@dataclass(frozen=True) +class MetronInfoIdentity: + evidence: ExactIdentityEvidence + primary: bool = False + + +@dataclass(frozen=True) +class MetronInfoReleaseReference: + """LOCG issue/variant reference, not a canonical issue attachment.""" + + external_id: str + primary: bool = False + + +@dataclass(frozen=True) +class MetronInfoArc: + name: str + number: int | None = None + evidence: ExactIdentityEvidence | None = None + + +@dataclass(frozen=True) +class MetronInfoData: + series: str | None = None + number: str | None = None + alternative_number: str | None = None + publisher: str | None = None + start_year: int | None = None + volume: int | None = None + series_format: str | None = None + issue_count: int | None = None + cover_date: date | None = None + store_date: date | None = None + stories: tuple[str, ...] = () + arcs: tuple[MetronInfoArc, ...] = () + primary_source: IdentityNamespace | None = None + identities: tuple[MetronInfoIdentity, ...] = () + release_references: tuple[MetronInfoReleaseReference, ...] = () + diagnostics: tuple[MetronInfoDiagnostic, ...] = () + has_unmapped_content: bool = False + + @property + def evidence(self) -> tuple[ExactIdentityEvidence, ...]: + return tuple(item.evidence for item in self.identities) + + +def parse_metroninfo(payload: bytes | str) -> MetronInfoData: + """Read local metadata only; callers retain authority and archive safety checks.""" + try: + root = _bounded_tree(payload) + except _ReadError as exc: + return MetronInfoData(diagnostics=(MetronInfoDiagnostic(exc.code, "MetronInfo"),)) + return _Reader(root).read() + + +class _ReadError(ValueError): + def __init__(self, code: MetronInfoDiagnosticCode) -> None: + super().__init__(code.value) + self.code = code + + +def _bounded_tree(payload: bytes | str) -> ElementTree.Element: + if len(payload) > MAX_METRONINFO_BYTES: + raise _ReadError(MetronInfoDiagnosticCode.TOO_LARGE) + try: + if isinstance(payload, str) and len(payload.encode("utf-8")) > MAX_METRONINFO_BYTES: + raise _ReadError(MetronInfoDiagnosticCode.TOO_LARGE) + normalized = normalize_xml_for_expat(payload) + header = normalized[:512] + if isinstance(header, bytes): + header = header.decode("ascii", errors="ignore") + encoding = re.search(r"<\?xml\b[^>]*\bencoding\s*=\s*['\"]([^'\"]+)", header) + if encoding and encoding[1].lower() not in { + "utf-8", + "utf-16", + "utf-16le", + "utf-16be", + "us-ascii", + }: + raise _ReadError(MetronInfoDiagnosticCode.UNSUPPORTED_ENCODING) + stream = io.StringIO(normalized) if isinstance(normalized, str) else io.BytesIO(normalized) + depth = nodes = 0 + root: ElementTree.Element | None = None + iterator = DefusedElementTree.iterparse( + stream, + events=("start", "end"), + forbid_dtd=True, + forbid_entities=True, + forbid_external=True, + ) + for event, node in iterator: + if event == "start": + if root is None: + root = node + depth += 1 + nodes += 1 + if ( + depth > MAX_METRONINFO_DEPTH + or nodes > MAX_METRONINFO_NODES + or len(node.attrib) > _MAX_ATTRIBUTES + ): + raise _ReadError(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT) + if node.tag.startswith(_XINCLUDE): + raise _ReadError(MetronInfoDiagnosticCode.UNSAFE_XML) + else: + depth -= 1 + except DefusedXmlException as exc: + raise _ReadError(MetronInfoDiagnosticCode.UNSAFE_XML) from exc + except (ElementTree.ParseError, UnsafeXmlError, UnicodeError, LookupError) as exc: + raise _ReadError(MetronInfoDiagnosticCode.INVALID_XML) from exc + if root is None or root.tag != "MetronInfo": + raise _ReadError(MetronInfoDiagnosticCode.INVALID_ROOT) + return root + + +class _Reader: + def __init__(self, root: ElementTree.Element) -> None: + self.root = root + self.diagnostics: list[MetronInfoDiagnostic] = [] + self.diagnostic_keys: set[MetronInfoDiagnostic] = set() + self.seen: dict[ElementTree.Element, set[str]] = {root: {"version", _SCHEMA_HINT}} + self.text_nodes: set[ElementTree.Element] = set() + self.identities: dict[ExactIdentityEvidence, bool] = {} + self.releases: dict[str, bool] = {} + + def diagnostic(self, code: MetronInfoDiagnosticCode, locator: str) -> None: + item = MetronInfoDiagnostic(code, locator) + if item not in self.diagnostic_keys: + self.diagnostic_keys.add(item) + self.diagnostics.append(item) + + def single( + self, parent: ElementTree.Element | None, tag: str, locator: str + ) -> ElementTree.Element | None: + if parent is None: + return None + nodes = parent.findall(tag) + if len(nodes) > 1: + self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_FIELD, locator) + return None + if not nodes: + return None + self.seen.setdefault(nodes[0], set()) + return nodes[0] + + def read_text(self, node: ElementTree.Element | None, locator: str) -> str | None: + if node is None: + return None + value = (node.text or "").strip() + if len(node) or len(value) > _MAX_FIELD_LENGTH: + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator) + return None + self.text_nodes.add(node) + return value or None + + def field(self, parent: ElementTree.Element | None, tag: str, locator: str) -> str | None: + return self.read_text(self.single(parent, tag, locator), locator) + + def integer( + self, + parent: ElementTree.Element | None, + tag: str, + locator: str, + minimum: int = 0, + maximum: int = 2**31 - 1, + ) -> int | None: + value = self.field(parent, tag, locator) + if value is None: + return None + if re.fullmatch(r"\+?[0-9]{1,10}", value) and minimum <= int(value) <= maximum: + return int(value) + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator) + return None + + def year(self, parent: ElementTree.Element | None, tag: str, locator: str) -> int | None: + value = self.field(parent, tag, locator) + if value is None: + return None + match = re.fullmatch(rf"([0-9]{{4}}){_CALENDAR_TIMEZONE}", value) + if match and int(match[1]) > 0: + return int(match[1]) + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator) + return None + + def date(self, tag: str) -> date | None: + value = self.field(self.root, tag, tag) + if value is None: + return None + try: + match = re.fullmatch(rf"([0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}){_CALENDAR_TIMEZONE}", value) + if match: + return date.fromisoformat(match[1]) + except ValueError: + pass + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, tag) + return None + + def external_id(self, value: str | None, locator: str) -> str | None: + normalized = (value or "").strip() + if ( + len(normalized) <= 255 + and normalized.isascii() + and normalized.isdecimal() + and normalized.lstrip("0") + ): + return normalized.lstrip("0") + self.diagnostic(MetronInfoDiagnosticCode.INVALID_ID, locator) + return None + + def claim( + self, namespace: IdentityNamespace, kind: MetadataEntityKind, value: str + ) -> ExactIdentityEvidence: + return ExactIdentityEvidence( + ExternalIdentityRef(namespace, kind, value), IdentityEvidenceKind.METRONINFO_XML + ) + + def read_ids(self) -> IdentityNamespace | None: + declarations: list[IdentityNamespace | None] = [] + invalid_primary = False + groups = self.root.findall("IDS") + if len(groups) > 1: + self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_FIELD, "IDS") + invalid_primary = True + for group_index, group in enumerate(groups, 1): + self.seen[group] = set() + for index, node in enumerate(group.findall("ID"), 1): + locator = f"IDS[{group_index}]/ID[{index}]" + self.seen[node] = {"source", "primary"} + namespace = _SOURCES.get(" ".join(node.get("source", "").lower().split())) + primary = node.get("primary", "false").strip() + if primary not in {"true", "false", "1", "0"}: + invalid_primary = True + self.diagnostic(MetronInfoDiagnosticCode.INVALID_PRIMARY, locator) + is_primary = primary in {"true", "1"} + if is_primary: + declarations.append(namespace) + if namespace is None: + self.diagnostic(MetronInfoDiagnosticCode.UNKNOWN_SOURCE, locator) + continue + value = self.external_id(self.read_text(node, locator), locator) + if value is None: + continue + if namespace is IdentityNamespace.LOCG: + self.releases[value] = self.releases.get(value, False) or is_primary + else: + claim = self.claim(namespace, MetadataEntityKind.ISSUE, value) + self.identities[claim] = self.identities.get(claim, False) or is_primary + if len(declarations) > 1: + self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_PRIMARY, "IDS") + if invalid_primary or len(declarations) != 1: + return None + return declarations[0] + + def parent_identity( + self, + node: ElementTree.Element | None, + namespace: IdentityNamespace | None, + kind: MetadataEntityKind, + locator: str, + ) -> ExactIdentityEvidence | None: + if node is None or "id" not in node.attrib: + return None + self.seen[node].add("id") + if namespace is None: + self.diagnostic(MetronInfoDiagnosticCode.MISSING_PRIMARY, locator) + return None + value = self.external_id(node.get("id"), locator) + return self.claim(namespace, kind, value) if value is not None else None + + def read_arcs(self, primary: IdentityNamespace | None) -> tuple[MetronInfoArc, ...]: + group = self.single(self.root, "Arcs", "Arcs") + if group is None: + return () + arcs = [] + for index, node in enumerate(group.findall("Arc"), 1): + self.seen[node] = set() + locator = f"Arcs/Arc[{index}]" + name = self.field(node, "Name", f"{locator}/Name") + number = self.integer(node, "Number", f"{locator}/Number", minimum=1) + identity = self.parent_identity(node, primary, MetadataEntityKind.STORY_ARC, locator) + if name: + arcs.append(MetronInfoArc(name, number, identity)) + return tuple(arcs) + + def read(self) -> MetronInfoData: + compatible = self.root.get("version") in {None, "1.0", "1.1"} + if not compatible: + self.diagnostic(MetronInfoDiagnosticCode.UNSUPPORTED_VERSION, "MetronInfo") + primary = self.read_ids() if compatible else None + series = self.single(self.root, "Series", "Series") + parent = self.parent_identity(series, primary, MetadataEntityKind.SERIES, "Series") + if parent is not None: + self.identities[parent] = True + if find_exact_identity_conflicts(self.identities): + self.diagnostic(MetronInfoDiagnosticCode.EXACT_ID_CONFLICT, "IDS") + stories_group = self.single(self.root, "Stories", "Stories") + stories = [] + if stories_group is not None: + for index, node in enumerate(stories_group.findall("Story"), 1): + self.seen[node] = set() + value = self.read_text(node, f"Stories/Story[{index}]") + if value: + stories.append(value) + data = MetronInfoData( + series=self.field(series, "Name", "Series/Name"), + number=self.field(self.root, "Number", "Number"), + alternative_number=self.field(self.root, "AlternativeNumber", "AlternativeNumber"), + publisher=self.field( + self.single(self.root, "Publisher", "Publisher"), "Name", "Publisher/Name" + ), + start_year=self.year(series, "StartYear", "Series/StartYear"), + volume=self.integer(series, "Volume", "Series/Volume"), + series_format=self.field(series, "Format", "Series/Format"), + issue_count=self.integer(series, "IssueCount", "Series/IssueCount", minimum=1), + cover_date=self.date("CoverDate"), + store_date=self.date("StoreDate"), + stories=tuple(stories), + arcs=self.read_arcs(primary), + primary_source=primary, + identities=tuple( + MetronInfoIdentity(claim, flag) for claim, flag in self.identities.items() + ), + release_references=tuple( + MetronInfoReleaseReference(value, flag) for value, flag in self.releases.items() + ), + ) + unmapped = any( + node not in self.seen + or set(node.attrib) - self.seen[node] + or (node not in self.text_nodes and (node.text or "").strip()) + or (node.tail or "").strip() + for node in self.root.iter() + ) + if unmapped: + self.diagnostic(MetronInfoDiagnosticCode.UNMAPPED_CONTENT, "MetronInfo") + return replace(data, diagnostics=tuple(self.diagnostics), has_unmapped_content=unmapped) diff --git a/tests/fixtures/metroninfo/v1_0.xml b/tests/fixtures/metroninfo/v1_0.xml new file mode 100644 index 00000000..a7ac018f --- /dev/null +++ b/tests/fixtures/metroninfo/v1_0.xml @@ -0,0 +1,13 @@ + + + 000101 + Example Press + + Harbor Lights22020 + Limited Series12 + + 13a + New ShoresHomeward + 2020-06-012020-04-15 + The Crossing2 + diff --git a/tests/fixtures/metroninfo/v1_1.xml b/tests/fixtures/metroninfo/v1_1.xml new file mode 100644 index 00000000..9ac8b15b --- /dev/null +++ b/tests/fixtures/metroninfo/v1_1.xml @@ -0,0 +1,12 @@ + + + + 101 + 00202 + 303 + 404 + + Harbor Lights2020 + 50-x150 + 4.0 + diff --git a/tests/unit/test_metroninfo.py b/tests/unit/test_metroninfo.py new file mode 100644 index 00000000..2071e540 --- /dev/null +++ b/tests/unit/test_metroninfo.py @@ -0,0 +1,382 @@ +"""Synthetic MetronInfo contracts based on the upstream 1.0 and 1.1 schemas.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from datetime import date +from pathlib import Path + +import pytest + +from pullbox.core.metadata_identity import ( + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metroninfo import ( + MAX_METRONINFO_BYTES, + MAX_METRONINFO_DEPTH, + MAX_METRONINFO_NODES, + MetronInfoData, + parse_metroninfo, +) +from pullbox.core.metroninfo import ( + MetronInfoDiagnosticCode as Code, +) + +FIXTURES = Path(__file__).parents[1] / "fixtures" / "metroninfo" + + +def _xml( + ids: str = '101', + *, + series: str = 'Harbor Lights', + extra: str = "", + version: str = "", +) -> str: + return f"{ids}{series}13a{extra}" + + +def _keys(data: MetronInfoData) -> set[tuple[str, str, str]]: + return { + (item.identity.namespace.value, item.identity.entity_kind.value, item.identity.external_id) + for item in data.evidence + } + + +def _codes(data: MetronInfoData) -> set[Code]: + return {item.code for item in data.diagnostics} + + +def test_v1_0_core_fields_and_parent_identity_use_primary_source() -> None: + data = parse_metroninfo((FIXTURES / "v1_0.xml").read_bytes()) + assert data.series == "Harbor Lights" + assert data.number == "13a" and data.publisher == "Example Press" + assert (data.volume, data.start_year, data.issue_count, data.series_format) == ( + 2, + 2020, + 12, + "Limited Series", + ) + assert data.cover_date == date(2020, 6, 1) and data.store_date == date(2020, 4, 15) + assert data.stories == ("New Shores", "Homeward") + assert _keys(data) == {("metron", "series", "21"), ("metron", "issue", "101")} + assert data.primary_source is IdentityNamespace.METRON + assert all(item.evidence_kind is IdentityEvidenceKind.METRONINFO_XML for item in data.evidence) + assert all(item.source_instance is None for item in data.evidence) + assert data.diagnostics == () + + +def test_v1_1_multi_source_ids_preserve_lettered_number_and_locg_discovery_scope() -> None: + data = parse_metroninfo((FIXTURES / "v1_1.xml").read_bytes()) + assert data.number == "50-x" and data.alternative_number == "150" + assert _keys(data) == { + ("metron", "series", "21"), + ("metron", "issue", "101"), + ("comicvine", "issue", "202"), + ("gcd", "issue", "303"), + } + assert [item.external_id for item in data.release_references] == ["404"] + assert not data.release_references[0].primary + assert data.has_unmapped_content and Code.UNMAPPED_CONTENT in _codes(data) + + +@pytest.mark.parametrize( + "source,namespace", + [ + ("Comic Vine", "comicvine"), + ("comicvine", "comicvine"), + (" GCD ", "gcd"), + ("Grand Comics Database", "gcd"), + ("metron", "metron"), + ("LOCG", "locg"), + ("League of Comic Geeks", "locg"), + ], +) +def test_known_source_aliases_assign_series_id_only_to_declared_provider( + source: str, namespace: str +) -> None: + data = parse_metroninfo(_xml(f'00101')) + assert (namespace, "series", "21") in _keys(data) + assert data.primary_source.value == namespace + if namespace == "locg": + assert len(data.evidence) == 1 and data.release_references[0].primary + else: + assert (namespace, "issue", "101") in _keys(data) + + +@pytest.mark.parametrize( + "ids,code", + [ + ('101', Code.MISSING_PRIMARY), + ('101', Code.INVALID_PRIMARY), + ( + '101' + '202', + Code.AMBIGUOUS_PRIMARY, + ), + ( + '1101', + Code.UNKNOWN_SOURCE, + ), + ], +) +def test_ambiguous_or_absent_primary_does_not_guess_parent_provider(ids: str, code: Code) -> None: + data = parse_metroninfo(_xml(ids)) + assert data.series == "Harbor Lights" + assert data.primary_source is None + assert all(item.identity.entity_kind is MetadataEntityKind.ISSUE for item in data.evidence) + assert code in _codes(data) + + +def test_duplicate_identical_ids_coalesce_but_primary_flag_is_retained() -> None: + data = parse_metroninfo( + _xml('00101101') + ) + assert len(data.identities) == 2 + assert all(item.primary for item in data.identities) + + +def test_repeated_primary_declarations_remain_ambiguous_even_with_same_id() -> None: + data = parse_metroninfo(_xml('101' * 2)) + assert data.primary_source is None + assert _keys(data) == {("metron", "issue", "101")} + assert Code.AMBIGUOUS_PRIMARY in _codes(data) + + +def test_exact_id_conflicts_keep_both_values_instead_of_primary_winning() -> None: + data = parse_metroninfo( + _xml('101102') + ) + assert {("metron", "issue", "101"), ("metron", "issue", "102")} <= _keys(data) + assert Code.EXACT_ID_CONFLICT in _codes(data) + + +@pytest.mark.parametrize( + "value", ["0", "-1", "1.5", "13a", "https://secret.invalid/?token=hidden", "1" * 256, "\u0661"] +) +def test_invalid_id_is_diagnosed_without_echoing_raw_input(value: str) -> None: + data = parse_metroninfo(_xml(f'{value}')) + assert not any(item.identity.entity_kind is MetadataEntityKind.ISSUE for item in data.evidence) + assert Code.INVALID_ID in _codes(data) + assert value not in repr(data.diagnostics) + + +def test_unknown_sources_are_diagnostics_not_invented_provider_keys() -> None: + data = parse_metroninfo( + _xml( + 'a-b-c5' + '101' + ) + ) + assert _keys(data) == {("metron", "series", "21"), ("metron", "issue", "101")} + assert Code.UNKNOWN_SOURCE in _codes(data) + assert "private-token" not in repr(data.diagnostics) + + +def test_story_arcs_are_separate_from_issue_identity_and_keep_reading_order() -> None: + data = parse_metroninfo((FIXTURES / "v1_0.xml").read_bytes()) + arc = data.arcs[0] + assert (arc.name, arc.number) == ("The Crossing", 2) + assert arc.evidence.identity.entity_kind is MetadataEntityKind.STORY_ARC + assert arc.evidence.identity.external_id == "91" + assert not any( + item.identity.entity_kind is MetadataEntityKind.STORY_ARC for item in data.evidence + ) + + +@pytest.mark.parametrize("version", ["1.0", "1.1"]) +def test_explicit_compatible_version_does_not_hide_identity(version: str) -> None: + assert len(parse_metroninfo(_xml(version=f' version="{version}"')).evidence) == 2 + + +def test_unknown_version_retains_core_text_without_guessing_id_semantics() -> None: + data = parse_metroninfo(_xml(version=' version="9.0"')) + assert data.series == "Harbor Lights" and data.number == "13a" + assert data.evidence == () and data.primary_source is None + assert Code.UNSUPPORTED_VERSION in _codes(data) + + +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"]) +def test_approved_unicode_encodings_are_supported(encoding: str) -> None: + declaration = "UTF-16" if encoding.startswith("utf-16") else "UTF-8" + payload = f'{_xml()}'.encode(encoding) + assert parse_metroninfo(payload).series == "Harbor Lights" + + +@pytest.mark.parametrize( + "payload,code", + [ + (b"", Code.INVALID_XML), + (b"", Code.INVALID_ROOT), + (b'', Code.INVALID_ROOT), + (b"", Code.UNSAFE_XML), + ( + b']>&secret;', + Code.UNSAFE_XML, + ), + ( + b'', + Code.UNSAFE_XML, + ), + ( + b'', + Code.UNSAFE_XML, + ), + (b'', Code.INVALID_XML), + ("\ud800", Code.INVALID_XML), + ], +) +def test_unsafe_or_invalid_xml_returns_safe_diagnostics_not_partial_evidence( + payload: bytes | str, code: Code +) -> None: + data = parse_metroninfo(payload) + assert data.evidence == () and data.series is None + assert code in _codes(data) + assert "secret" not in repr(data.diagnostics) and "private" not in repr(data.diagnostics) + + +@pytest.mark.parametrize( + "payload", + [b" " * (MAX_METRONINFO_BYTES + 1), "\u00e9" * (MAX_METRONINFO_BYTES // 2 + 1)], + ids=["bytes", "unicode"], +) +def test_input_byte_limit_precedes_parsing(payload: bytes | str) -> None: + assert Code.TOO_LARGE in _codes(parse_metroninfo(payload)) + + +def test_depth_and_node_limits_fail_closed() -> None: + deep = ( + "" + + "" * MAX_METRONINFO_DEPTH + + "" * MAX_METRONINFO_DEPTH + + "" + ) + wide = "" + "" * MAX_METRONINFO_NODES + "" + assert Code.COMPLEXITY_LIMIT in _codes(parse_metroninfo(deep)) + assert Code.COMPLEXITY_LIMIT in _codes(parse_metroninfo(wide)) + + +def test_remote_schema_hint_is_never_fetched() -> None: + data = parse_metroninfo( + _xml().replace( + "", + '', + ) + ) + assert data.series == "Harbor Lights" and len(data.evidence) == 2 + assert "secret" not in repr(data.diagnostics) + + +def test_duplicate_series_nodes_keep_issue_ids_but_do_not_choose_a_parent() -> None: + data = parse_metroninfo( + _xml( + series='One' + 'Two' + ) + ) + assert data.series is None + assert _keys(data) == {("metron", "issue", "101")} + assert Code.AMBIGUOUS_FIELD in _codes(data) + + +def test_bad_optional_fields_do_not_erase_valid_identity() -> None: + data = parse_metroninfo( + _xml( + series='Harbor Lights' + "bad-1", + extra="2020-02-31", + ) + ) + assert data.start_year is None and data.issue_count is None and data.cover_date is None + assert len(data.evidence) == 2 and Code.INVALID_FIELD in _codes(data) + + +def test_results_are_immutable() -> None: + with pytest.raises(FrozenInstanceError): + parse_metroninfo(_xml()).series = "Changed" + + +@pytest.mark.parametrize("timezone", ["Z", "+00:00", "-12:30", "+14:00"]) +def test_xsd_calendar_dates_and_years_preserve_local_calendar_with_timezone(timezone: str) -> None: + data = parse_metroninfo( + _xml( + series="Harbor Lights" + f"2020{timezone}", + extra=f"2020-06-01{timezone}", + ) + ) + assert data.start_year == 2020 and data.cover_date == date(2020, 6, 1) + + +@pytest.mark.parametrize("year", ["2", "020", "+2020", "2020+14:01", "2020+25:00"]) +def test_invalid_calendar_year_is_not_silently_accepted(year: str) -> None: + data = parse_metroninfo( + _xml(series=f"Harbor Lights{year}") + ) + assert data.start_year is None and Code.INVALID_FIELD in _codes(data) + + +@pytest.mark.parametrize( + "extra", + [ + "unexpected text", + "retained elsewhere", + 'Other', + ], +) +def test_unmapped_content_cannot_be_silently_lost_by_a_future_writer(extra: str) -> None: + data = parse_metroninfo(_xml(extra=extra)) + assert data.has_unmapped_content and Code.UNMAPPED_CONTENT in _codes(data) + + +def test_unknown_series_mixed_content_is_reported_without_hiding_valid_fields() -> None: + data = parse_metroninfo( + _xml(series='unmapped textHarbor Lights') + ) + assert data.series == "Harbor Lights" and len(data.evidence) == 2 + assert data.has_unmapped_content + + +@pytest.mark.parametrize("encoding", ["iso-8859-1", "utf-7"]) +def test_unapproved_declared_encodings_are_not_decoded(encoding: str) -> None: + payload = f'{_xml()}'.encode(encoding) + assert Code.UNSUPPORTED_ENCODING in _codes(parse_metroninfo(payload)) + + +def test_unicode_input_with_utf16_declaration_is_already_decoded() -> None: + assert ( + parse_metroninfo(f'{_xml()}').series + == "Harbor Lights" + ) + + +def test_attribute_count_is_bounded() -> None: + attributes = " ".join(f'a{index}="value"' for index in range(17)) + assert Code.COMPLEXITY_LIMIT in _codes(parse_metroninfo(f"")) + + +def test_duplicate_scalar_fields_are_reported_instead_of_picking_the_first() -> None: + data = parse_metroninfo(_xml(extra="13b")) + assert data.number is None and Code.AMBIGUOUS_FIELD in _codes(data) + + +def test_reading_order_survives_missing_identity_context() -> None: + data = parse_metroninfo( + _xml( + '101', + extra='The Crossing2', + ) + ) + assert data.arcs[0].name == "The Crossing" and data.arcs[0].number == 2 + assert data.arcs[0].evidence is None and Code.MISSING_PRIMARY in _codes(data) + + +def test_parser_cannot_make_network_or_filesystem_calls(monkeypatch: pytest.MonkeyPatch) -> None: + def forbidden(*args: object, **kwargs: object) -> None: + pytest.fail("Local metadata parser attempted external I/O") + + monkeypatch.setattr("builtins.open", forbidden) + monkeypatch.setattr("socket.create_connection", forbidden) + assert parse_metroninfo(_xml()).series == "Harbor Lights" From 6b4be93b3c832a65d4f6b8b515264c76b350de55 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 19:11:53 -0700 Subject: [PATCH 08/97] feat: read embedded metadata in one bounded archive pass --- src/pullbox/core/archive.py | 14 + src/pullbox/core/archive_metadata.py | 346 ++++++++++++++++++++++ tests/fixtures/metroninfo/README.md | 26 ++ tests/unit/test_archive_metadata.py | 428 +++++++++++++++++++++++++++ 4 files changed, 814 insertions(+) create mode 100644 src/pullbox/core/archive_metadata.py create mode 100644 tests/fixtures/metroninfo/README.md create mode 100644 tests/unit/test_archive_metadata.py diff --git a/src/pullbox/core/archive.py b/src/pullbox/core/archive.py index 0ebc3e58..5c5c57dc 100644 --- a/src/pullbox/core/archive.py +++ b/src/pullbox/core/archive.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath from stat import S_ISLNK +from typing import TYPE_CHECKING import structlog @@ -21,6 +22,9 @@ from pullbox.core.comicinfo import ComicInfoData, parse_comicinfo from pullbox.core.rar_backend import RarBackendUnavailableError, configure_rarfile_backend +if TYPE_CHECKING: + from pullbox.core.archive_metadata import ArchiveMetadataFiles + logger = structlog.get_logger(__name__) _ARCHIVE_IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff"}) @@ -160,6 +164,16 @@ def read_comicinfo(self, *, entries: list[str] | None = None) -> ComicInfoData | xml_bytes = self.read_file(comicinfo_name) return parse_comicinfo(xml_bytes.decode("utf-8", errors="replace")) + def read_metadata_files( + self, *, max_solid_scan_bytes: int = 2000 * 1024 * 1024 + ) -> ArchiveMetadataFiles: + """Read ComicInfo and MetronInfo together without changing legacy readers.""" + from pullbox.core.archive_metadata import read_archive_metadata + + return read_archive_metadata( + self._path, self.format, max_solid_scan_bytes=max_solid_scan_bytes + ) + # -- CBZ (ZIP) ---------------------------------------------------------- def _list_zip(self) -> list[str]: diff --git a/src/pullbox/core/archive_metadata.py b/src/pullbox/core/archive_metadata.py new file mode 100644 index 00000000..d1c35e9e --- /dev/null +++ b/src/pullbox/core/archive_metadata.py @@ -0,0 +1,346 @@ +"""Read bounded embedded metadata without extracting comic pages to disk.""" + +from __future__ import annotations + +import enum +import io +import tarfile +import threading +import zipfile +from dataclasses import dataclass, replace +from pathlib import PurePosixPath +from stat import S_IFMT, S_ISREG +from typing import IO, TYPE_CHECKING + +from py7zr.io import Py7zIO, WriterFactory + +from pullbox.core.archive import ArchiveError, ArchiveResourceLimitError, comicinfo_member_sort_key +from pullbox.core.file_safety import has_archive_member_path_traversal +from pullbox.core.rar_backend import RarBackendUnavailableError + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from contextlib import AbstractContextManager + from pathlib import Path + +MAX_METADATA_BYTES = 2 * 1024 * 1024 + + +class MetadataReadDiagnostic(enum.StrEnum): + DUPLICATE_ENTRIES = "duplicate_entries" + UNSAFE_ENTRY = "unsafe_entry" + SIZE_LIMIT = "size_limit" + SOLID_SCAN_LIMIT = "solid_scan_limit" + UNREADABLE = "unreadable" + + +@dataclass(frozen=True) +class MetadataFile: + name: str + entry_count: int = 0 + payload: bytes | None = None + diagnostics: tuple[MetadataReadDiagnostic, ...] = () + + +@dataclass(frozen=True) +class ArchiveMetadataFiles: + comicinfo: MetadataFile + metroninfo: MetadataFile + + +def read_archive_metadata( + path: Path, archive_type: str, *, max_solid_scan_bytes: int +) -> ArchiveMetadataFiles: + """Read both XML members in one archive session; this is not a safety approval.""" + if isinstance(max_solid_scan_bytes, bool) or max_solid_scan_bytes <= 0: + raise ValueError("Solid metadata scan budget must be positive") + try: + if archive_type == "cbz": + with zipfile.ZipFile(path, "r") as archive: + return read_open_zip_metadata(archive) + if archive_type == "cbt": + with tarfile.open(path, "r:*") as tar: + candidates = [ + _Candidate(member, member.name, member.size, None, member.isfile()) + for member in tar.getmembers() + ] + + def open_tar(member: tarfile.TarInfo) -> IO[bytes]: + stream = tar.extractfile(member) + if stream is None: + raise ArchiveError("Metadata member is unavailable") + return stream + + return _read_members(candidates, open_tar, (tarfile.TarError,)) + if archive_type == "cbr": + return _read_rar(path, max_solid_scan_bytes) + if archive_type == "cb7": + return _read_7z(path, max_solid_scan_bytes) + except ( + OSError, + EOFError, + RuntimeError, + ValueError, + zipfile.BadZipFile, + tarfile.TarError, + ) as exc: + raise ArchiveError("Archive metadata could not be inspected") from exc + raise ArchiveError("Unsupported archive format for embedded metadata") + + +@dataclass(frozen=True) +class _Candidate[T]: + member: T + name: str + size: int + compressed_size: int | None + regular: bool + + +def _select[T]( + candidates: Sequence[_Candidate[T]], name: str +) -> tuple[MetadataFile, _Candidate[T] | None]: + matches = sorted( + ( + candidate + for candidate in candidates + if PurePosixPath(candidate.name.replace("\\", "/")).name.casefold() == name.casefold() + ), + key=lambda candidate: comicinfo_member_sort_key(candidate.name), + ) + diagnostics = [] + if len(matches) > 1: + diagnostics.append(MetadataReadDiagnostic.DUPLICATE_ENTRIES) + result = MetadataFile(name, len(matches), diagnostics=tuple(diagnostics)) + if not matches: + return result, None + chosen = matches[0] + if ( + not chosen.regular + or has_archive_member_path_traversal(chosen.name) + or "\x00" in chosen.name + ): + return _with_error(result, MetadataReadDiagnostic.UNSAFE_ENTRY), None + if ( + chosen.size < 0 + or chosen.size > MAX_METADATA_BYTES + or ( + chosen.compressed_size is not None + and (chosen.compressed_size < 0 or chosen.compressed_size > MAX_METADATA_BYTES) + ) + ): + return _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT), None + return result, chosen + + +def _with_error(result: MetadataFile, diagnostic: MetadataReadDiagnostic) -> MetadataFile: + return replace(result, payload=None, diagnostics=(*result.diagnostics, diagnostic)) + + +def _read_members[T]( + candidates: Sequence[_Candidate[T]], + open_member: Callable[[T], AbstractContextManager[IO[bytes]]], + errors: tuple[type[Exception], ...] = (), + *, + solid_blocked: bool = False, +) -> ArchiveMetadataFiles: + results = [] + read_errors: tuple[type[Exception], ...] = ( + OSError, + EOFError, + RuntimeError, + ValueError, + ArchiveError, + *errors, + ) + for name in ("ComicInfo.xml", "MetronInfo.xml"): + result, chosen = _select(candidates, name) + if chosen is not None: + if solid_blocked: + result = _with_error(result, MetadataReadDiagnostic.SOLID_SCAN_LIMIT) + else: + try: + with open_member(chosen.member) as stream: + payload = stream.read(MAX_METADATA_BYTES + 1) + if len(payload) > MAX_METADATA_BYTES: + result = _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT) + elif len(payload) != chosen.size: + result = _with_error(result, MetadataReadDiagnostic.UNREADABLE) + else: + result = replace(result, payload=payload) + except read_errors: + result = _with_error(result, MetadataReadDiagnostic.UNREADABLE) + results.append(result) + return ArchiveMetadataFiles(*results) + + +def read_open_zip_metadata(archive: zipfile.ZipFile) -> ArchiveMetadataFiles: + """Reuse an already-open ZIP inspection without reopening or extracting it.""" + candidates = [ + _Candidate( + member, + member.filename, + member.file_size, + member.compress_size, + not member.is_dir() + and (S_IFMT(member.external_attr >> 16) == 0 or S_ISREG(member.external_attr >> 16)), + ) + for member in archive.infolist() + ] + return _read_members( + candidates, lambda member: archive.open(member, "r"), (zipfile.BadZipFile,) + ) + + +def _read_rar(path: Path, max_solid_scan_bytes: int) -> ArchiveMetadataFiles: + import rarfile # type: ignore[import-untyped] + + from pullbox.core.rar_backend import configure_rarfile_backend + + try: + configure_rarfile_backend() + with rarfile.RarFile(path, "r") as archive: + candidates = [ + _Candidate( + member, + str(member.filename), + int(member.file_size), + int(member.compress_size), + bool(member.is_file()) and not bool(member.is_symlink()), + ) + for member in archive.infolist() + ] + return _read_members( + candidates, + lambda member: archive.open(member, "r"), + (rarfile.Error,), + solid_blocked=bool(archive.is_solid()) + and sum(member.size for member in candidates) > max_solid_scan_bytes, + ) + except (rarfile.Error, RarBackendUnavailableError) as exc: + raise ArchiveError("RAR metadata could not be inspected") from exc + + +class _BoundedBuffer(Py7zIO): + def __init__(self) -> None: + self._buffer = io.BytesIO() + self._size = 0 + self._lock = threading.Lock() + + def write(self, data: bytes | bytearray) -> int: + with self._lock: + end = self._buffer.tell() + len(data) + if end > MAX_METADATA_BYTES: + raise ArchiveResourceLimitError("Embedded metadata size limit exceeded") + written = self._buffer.write(data) + self._size = max(self._size, end) + return written + + def read(self, size: int | None = None) -> bytes: + with self._lock: + return self._buffer.read(size) + + def seek(self, offset: int, whence: int = 0) -> int: + with self._lock: + base = {0: 0, 1: self._buffer.tell(), 2: self._size}.get(whence) + if base is None or not 0 <= base + offset <= MAX_METADATA_BYTES: + raise ArchiveResourceLimitError("Embedded metadata seek limit exceeded") + return self._buffer.seek(offset, whence) + + def flush(self) -> None: + return None + + def size(self) -> int: + with self._lock: + return self._size + + +class _MetadataFactory(WriterFactory): + def __init__(self, names: Sequence[str]) -> None: + self.products = {name: _BoundedBuffer() for name in names} + + def create(self, filename: str) -> Py7zIO: + product = self.products.get(filename) + if product is None: + raise ArchiveError("Unexpected metadata extraction target") + return product + + +def _read_7z(path: Path, max_solid_scan_bytes: int) -> ArchiveMetadataFiles: + import py7zr + from py7zr.exceptions import ArchiveError as SevenZipError + from py7zr.exceptions import PasswordRequired + + try: + with py7zr.SevenZipFile(path, "r", max_extract_size=max_solid_scan_bytes) as archive: + candidates = [ + _Candidate( + member.filename, + member.filename, + int(member.uncompressed or 0), + # Solid 7z reports the packed block, not a member's compressed size. + None, + bool(member.is_file) and not bool(member.is_symlink), + ) + for member in archive.list() + ] + plans = [_select(candidates, name) for name in ("ComicInfo.xml", "MetronInfo.xml")] + solid_blocked = ( + archive.archiveinfo().solid + and sum(member.size for member in candidates) > max_solid_scan_bytes + ) + targets = [] + results = [] + for result, chosen in plans: + if chosen is not None: + if solid_blocked: + result = _with_error(result, MetadataReadDiagnostic.SOLID_SCAN_LIMIT) + elif sum(member.name == chosen.name for member in candidates) > 1: + # py7zr selects by name, so it cannot address one exact duplicate safely. + result = _with_error(result, MetadataReadDiagnostic.UNREADABLE) + else: + targets.append(chosen) + results.append(result) + if not targets: + return ArchiveMetadataFiles(*results) + factory = _MetadataFactory([member.name for member in targets]) + try: + archive.extract( + targets=[member.name for member in targets], recursive=False, factory=factory + ) + for index, (_result, chosen) in enumerate(plans): + if chosen not in targets or chosen is None: + continue + buffer = factory.products[chosen.name] + if buffer.size() != chosen.size: + results[index] = _with_error( + results[index], MetadataReadDiagnostic.UNREADABLE + ) + else: + buffer.seek(0) + results[index] = replace(results[index], payload=buffer.read()) + except ArchiveResourceLimitError: + results = [ + _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT) + if chosen in targets + else result + for result, (_old, chosen) in zip(results, plans, strict=True) + ] + except ( + SevenZipError, + PasswordRequired, + ArchiveError, + OSError, + EOFError, + RuntimeError, + ValueError, + ): + results = [ + _with_error(result, MetadataReadDiagnostic.UNREADABLE) + if chosen in targets + else result + for result, (_old, chosen) in zip(results, plans, strict=True) + ] + return ArchiveMetadataFiles(*results) + except (SevenZipError, PasswordRequired) as exc: + raise ArchiveError("7z metadata could not be inspected") from exc diff --git a/tests/fixtures/metroninfo/README.md b/tests/fixtures/metroninfo/README.md new file mode 100644 index 00000000..f780f655 --- /dev/null +++ b/tests/fixtures/metroninfo/README.md @@ -0,0 +1,26 @@ +# Synthetic MetronInfo Fixtures + +These documents contain invented titles, publishers, and identifiers. They are +not provider responses, real comic metadata, or licensed comic content. + +Field shapes were checked against the upstream MetronInfo schemas at commit +`77b9fdbe489568b9e047206005e7f6b0700bb3f0`: + +- [MetronInfo 1.0 schema](https://github.com/Metron-Project/metroninfo/blob/77b9fdbe489568b9e047206005e7f6b0700bb3f0/schema/v1.0/MetronInfo.xsd) +- [MetronInfo 1.1 schema](https://github.com/Metron-Project/metroninfo/blob/77b9fdbe489568b9e047206005e7f6b0700bb3f0/schema/v1.1/MetronInfo.xsd) + +The root has no required version attribute. The fixtures exercise the core +reader, not full schema validation or lossless XML rewriting. The 1.1 fixture +includes an unmapped community field deliberately; it must not become canonical +issue identity or silently disappear in a later rewrite. + +`test_metroninfo.py` covers parsed fields, scoped identities, LOCG discovery +references, conflicts, supported encodings, and XML/resource boundaries. +`test_archive_metadata.py` builds temporary ZIP, TAR, and 7z archives with +synthetic XML and page bytes. RAR stream behavior uses a mocked backend because +the test environment does not create RAR archives. It also proves that a ZIP +with a `.cbr` suffix is detected by its container header. + +The paired archive probe returns bounded bytes and read diagnostics, not a +match, safety approval, or permission to rewrite. Existing import consumers +remain on their current read path until multi-source reconciliation is wired. diff --git a/tests/unit/test_archive_metadata.py b/tests/unit/test_archive_metadata.py new file mode 100644 index 00000000..7ce4f818 --- /dev/null +++ b/tests/unit/test_archive_metadata.py @@ -0,0 +1,428 @@ +"""Synthetic paired-metadata probes, independent of matching and live providers.""" + +from __future__ import annotations + +import io +import random +import tarfile +import zipfile +from stat import S_IFLNK +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import patch + +import py7zr +import pytest + +from pullbox.core.archive import ArchiveError, ArchiveReader, ArchiveResourceLimitError +from pullbox.core.archive_metadata import MAX_METADATA_BYTES, MetadataReadDiagnostic +from pullbox.core.comicinfo import parse_comicinfo +from pullbox.core.metroninfo import parse_metroninfo + +if TYPE_CHECKING: + from pathlib import Path + +COMICINFO = b"Harbor Lights13a" +METRONINFO = b'10113a' + + +def _archive(path: Path, entries: list[tuple[str, bytes]]) -> Path: + if path.suffix in {".cbz", ".cbr"}: + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, payload in entries: + archive.writestr(name, payload) + elif path.suffix == ".cbt": + with tarfile.open(path, "w") as archive: + for name, payload in entries: + member = tarfile.TarInfo(name) + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + else: + with py7zr.SevenZipFile(path, "w") as archive: + for name, payload in entries: + archive.writestr(payload, name) + return path + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbr", ".cbt", ".cb7"]) +@pytest.mark.parametrize("nested", [False, True]) +def test_reads_both_metadata_files_without_changing_archive( + tmp_path: Path, suffix: str, nested: bool +) -> None: + prefix = "metadata/" if nested else "" + path = _archive( + tmp_path / f"issue{suffix}", + [ + (prefix + "cOmIcInFo.XmL", COMICINFO), + (prefix + "mEtRoNiNfO.xMl", METRONINFO), + ("pages/001.jpg", b"synthetic page"), + ], + ) + original = path.read_bytes() + before = set(tmp_path.rglob("*")) + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.payload == COMICINFO and result.metroninfo.payload == METRONINFO + assert result.comicinfo.entry_count == result.metroninfo.entry_count == 1 + assert result.comicinfo.diagnostics == result.metroninfo.diagnostics == () + assert path.read_bytes() == original and set(tmp_path.rglob("*")) == before + + +@pytest.mark.parametrize( + "suffix,backend", + [ + (".cbz", "zipfile.ZipFile"), + (".cbt", "tarfile.open"), + (".cb7", "py7zr.SevenZipFile"), + ], +) +def test_probe_opens_archive_once(tmp_path: Path, suffix: str, backend: str) -> None: + path = _archive( + tmp_path / f"issue{suffix}", + [ + ("ComicInfo.xml", COMICINFO), + ("MetronInfo.xml", METRONINFO), + ], + ) + module, name = backend.split(".") + original = getattr({"zipfile": zipfile, "tarfile": tarfile, "py7zr": py7zr}[module], name) + with patch(backend, wraps=original) as opened: + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload == METRONINFO + assert opened.call_count == 1 + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) +def test_missing_optional_metadata_is_not_a_failure(tmp_path: Path, suffix: str) -> None: + result = ArchiveReader( + _archive(tmp_path / f"issue{suffix}", [("001.jpg", b"page")]) + ).read_metadata_files() + assert result.comicinfo.payload is result.metroninfo.payload is None + assert result.comicinfo.entry_count == result.metroninfo.entry_count == 0 + assert result.comicinfo.diagnostics == result.metroninfo.diagnostics == () + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) +def test_duplicate_metadata_selects_root_deterministically_and_reports_ambiguity( + tmp_path: Path, suffix: str +) -> None: + path = _archive( + tmp_path / f"issue{suffix}", + [ + ("nested/MetronInfo.xml", b"other"), + ("MetronInfo.xml", METRONINFO), + ("ComicInfo.xml", COMICINFO), + ], + ) + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload == METRONINFO + assert result.metroninfo.entry_count == 2 + assert result.metroninfo.diagnostics == (MetadataReadDiagnostic.DUPLICATE_ENTRIES,) + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt"]) +def test_exact_duplicate_names_read_first_member_not_backend_last_wins( + tmp_path: Path, suffix: str +) -> None: + if suffix == ".cbz": + with pytest.warns(UserWarning, match="Duplicate name"): + path = _archive( + tmp_path / f"issue{suffix}", + [("MetronInfo.xml", METRONINFO), ("MetronInfo.xml", b"other")], + ) + else: + path = _archive( + tmp_path / f"issue{suffix}", + [("MetronInfo.xml", METRONINFO), ("MetronInfo.xml", b"other")], + ) + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload == METRONINFO + assert MetadataReadDiagnostic.DUPLICATE_ENTRIES in result.metroninfo.diagnostics + + +@pytest.mark.parametrize( + "name", ["../MetronInfo.xml", "/MetronInfo.xml", r"C:\MetronInfo.xml", r"..\MetronInfo.xml"] +) +def test_unsafe_metadata_member_is_never_read(tmp_path: Path, name: str) -> None: + path = _archive(tmp_path / "unsafe.cbz", [(name, METRONINFO), ("ComicInfo.xml", COMICINFO)]) + original = zipfile.ZipFile.open + reads: list[str] = [] + + def tracked( + archive: zipfile.ZipFile, member: object, *args: object, **kwargs: object + ) -> object: + reads.append(member.filename if isinstance(member, zipfile.ZipInfo) else str(member)) + return original(archive, member, *args, **kwargs) + + with patch("zipfile.ZipFile.open", tracked): + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.payload == COMICINFO + assert result.metroninfo.payload is None + assert MetadataReadDiagnostic.UNSAFE_ENTRY in result.metroninfo.diagnostics + assert reads == ["ComicInfo.xml"] + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt"]) +def test_metadata_links_are_not_followed(tmp_path: Path, suffix: str) -> None: + path = tmp_path / f"link{suffix}" + if suffix == ".cbz": + with zipfile.ZipFile(path, "w") as archive: + member = zipfile.ZipInfo("MetronInfo.xml") + member.create_system = 3 + member.external_attr = (S_IFLNK | 0o777) << 16 + archive.writestr(member, "private-secret") + else: + with tarfile.open(path, "w") as archive: + member = tarfile.TarInfo("MetronInfo.xml") + member.type = tarfile.SYMTYPE + member.linkname = "private-secret" + archive.addfile(member) + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload is None + assert result.metroninfo.diagnostics == (MetadataReadDiagnostic.UNSAFE_ENTRY,) + assert "private-secret" not in repr(result) + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) +def test_oversized_metadata_does_not_discard_the_other_file(tmp_path: Path, suffix: str) -> None: + path = _archive( + tmp_path / f"large{suffix}", + [ + ("ComicInfo.xml", COMICINFO), + ("MetronInfo.xml", b"x" * (MAX_METADATA_BYTES + 1)), + ], + ) + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.payload == COMICINFO + assert result.metroninfo.payload is None + assert result.metroninfo.diagnostics == (MetadataReadDiagnostic.SIZE_LIMIT,) + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) +def test_malformed_optional_xml_does_not_hide_other_metadata(tmp_path: Path, suffix: str) -> None: + path = _archive( + tmp_path / f"bad-xml{suffix}", + [("ComicInfo.xml", COMICINFO), ("MetronInfo.xml", b"")], + ) + result = ArchiveReader(path).read_metadata_files() + assert parse_comicinfo(result.comicinfo.payload).series == "Harbor Lights" + assert parse_metroninfo(result.metroninfo.payload).diagnostics + + +def test_cb7_uses_bounded_memory_factory_not_disk_extraction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = _archive( + tmp_path / "memory.cb7", [("ComicInfo.xml", COMICINFO), ("MetronInfo.xml", METRONINFO)] + ) + original = py7zr.SevenZipFile.extract + factories: list[object] = [] + + def tracked(archive: py7zr.SevenZipFile, *args: object, **kwargs: object) -> None: + assert kwargs.get("factory") is not None and kwargs.get("path") is None + assert kwargs.get("recursive") is False + factories.append(kwargs["factory"]) + original(archive, *args, **kwargs) + + monkeypatch.setattr(py7zr.SevenZipFile, "extract", tracked) + assert ArchiveReader(path).read_metadata_files().metroninfo.payload == METRONINFO + assert len(factories) == 1 + + +def test_solid_scan_budget_prevents_decompressing_large_cb7_for_tiny_metadata( + tmp_path: Path, +) -> None: + path = _archive( + tmp_path / "solid.cb7", [("page.jpg", b"x" * 4096), ("MetronInfo.xml", METRONINFO)] + ) + with patch("py7zr.SevenZipFile.extract") as extract: + result = ArchiveReader(path).read_metadata_files(max_solid_scan_bytes=1024) + extract.assert_not_called() + assert result.metroninfo.diagnostics == (MetadataReadDiagnostic.SOLID_SCAN_LIMIT,) + + +@pytest.mark.parametrize("limit", [0, -1, True]) +def test_invalid_scan_budget_is_rejected_before_open(tmp_path: Path, limit: int) -> None: + with pytest.raises(ValueError, match="positive"): + ArchiveReader(tmp_path / "missing.cbz").read_metadata_files(max_solid_scan_bytes=limit) + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7", ".cbr", ".pdf"]) +def test_unreadable_archive_has_safe_error(tmp_path: Path, suffix: str) -> None: + path = tmp_path / f"secret{suffix}" + path.write_bytes(b"broken") + with pytest.raises(ArchiveError) as error: + ArchiveReader(path).read_metadata_files() + assert "secret" not in str(error.value) and str(tmp_path) not in str(error.value) + + +def test_rar_configures_backend_once_and_streams_members_without_disk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import rarfile + + from pullbox.core import rar_backend + + path = tmp_path / "real.cbr" + path.write_bytes(b"Rar!\x1a\x07\x00fake") + calls: list[str] = [] + payloads = {"ComicInfo.xml": COMICINFO, "MetronInfo.xml": METRONINFO} + + class FakeRar: + def __init__(self, *_args: object, **_kwargs: object) -> None: + calls.append("open") + + def __enter__(self) -> FakeRar: + return self + + def __exit__(self, *_args: object) -> None: + calls.append("close") + + def infolist(self) -> list[SimpleNamespace]: + return [ + SimpleNamespace( + filename=name, + file_size=len(data), + compress_size=len(data), + is_file=lambda: True, + is_symlink=lambda: False, + ) + for name, data in payloads.items() + ] + + def is_solid(self) -> bool: + return False + + def open(self, info: SimpleNamespace, *_args: object) -> io.BytesIO: + calls.append(info.filename) + return io.BytesIO(payloads[info.filename]) + + monkeypatch.setattr(rar_backend, "configure_rarfile_backend", lambda: calls.append("configure")) + monkeypatch.setattr(rarfile, "RarFile", FakeRar) + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.payload == COMICINFO and result.metroninfo.payload == METRONINFO + assert calls == ["configure", "open", "ComicInfo.xml", "MetronInfo.xml", "close"] + + +def test_cb7_packed_block_size_is_not_the_metadata_member_compressed_size(tmp_path: Path) -> None: + path = _archive( + tmp_path / "large-block.cb7", + [ + ("MetronInfo.xml", METRONINFO), + ("page.jpg", random.Random(1234).randbytes(MAX_METADATA_BYTES + 1024)), + ], + ) + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload == METRONINFO and not result.metroninfo.diagnostics + + +def test_cb7_exact_duplicate_names_are_reported_without_name_based_overwrite( + tmp_path: Path, +) -> None: + path = _archive( + tmp_path / "duplicates.cb7", + [ + ("MetronInfo.xml", METRONINFO), + ("MetronInfo.xml", b"different"), + ("ComicInfo.xml", COMICINFO), + ], + ) + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.payload == COMICINFO + assert result.metroninfo.payload is None + assert result.metroninfo.diagnostics == ( + MetadataReadDiagnostic.DUPLICATE_ENTRIES, + MetadataReadDiagnostic.UNREADABLE, + ) + + +@pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) +def test_relative_current_directory_metadata_is_read(tmp_path: Path, suffix: str) -> None: + path = _archive(tmp_path / f"relative{suffix}", [("./MetronInfo.xml", METRONINFO)]) + assert ArchiveReader(path).read_metadata_files().metroninfo.payload == METRONINFO + + +@pytest.mark.parametrize( + "payload,diagnostic", + [ + (b"x", MetadataReadDiagnostic.UNREADABLE), + (b"x" * (MAX_METADATA_BYTES + 1), MetadataReadDiagnostic.SIZE_LIMIT), + ], + ids=["truncated", "oversized"], +) +def test_zip_actual_stream_length_is_bounded_and_checked( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + payload: bytes, + diagnostic: MetadataReadDiagnostic, +) -> None: + path = _archive(tmp_path / "length.cbz", [("MetronInfo.xml", METRONINFO)]) + reads: list[int] = [] + + class FakeStream(io.BytesIO): + def read(self, size: int = -1) -> bytes: + reads.append(size) + return super().read(size) + + monkeypatch.setattr(zipfile.ZipFile, "open", lambda *args, **kwargs: FakeStream(payload)) + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload is None and diagnostic in result.metroninfo.diagnostics + assert reads == [MAX_METADATA_BYTES + 1] + + +def test_unreadable_member_does_not_erase_other_document_or_echo_error(tmp_path: Path) -> None: + path = _archive( + tmp_path / "errors.cbz", [("ComicInfo.xml", COMICINFO), ("MetronInfo.xml", METRONINFO)] + ) + original = zipfile.ZipFile.open + + def fail_one( + archive: zipfile.ZipFile, member: zipfile.ZipInfo, *args: object, **kwargs: object + ) -> object: + if member.filename == "ComicInfo.xml": + raise RuntimeError("private-token-and-source-path") + return original(archive, member, *args, **kwargs) + + with patch("zipfile.ZipFile.open", fail_one): + result = ArchiveReader(path).read_metadata_files() + assert result.comicinfo.diagnostics == (MetadataReadDiagnostic.UNREADABLE,) + assert result.metroninfo.payload == METRONINFO + assert "private-token" not in repr(result) + + +def test_metadata_reader_does_not_swallow_cancellation(tmp_path: Path) -> None: + path = _archive(tmp_path / "cancel.cbz", [("MetronInfo.xml", METRONINFO)]) + with ( + patch("zipfile.ZipFile.open", side_effect=KeyboardInterrupt), + pytest.raises(KeyboardInterrupt), + ): + ArchiveReader(path).read_metadata_files() + + +def test_open_zip_probe_reuses_the_callers_archive(tmp_path: Path) -> None: + from pullbox.core.archive_metadata import read_open_zip_metadata + + path = _archive( + tmp_path / "reuse.cbz", [("ComicInfo.xml", COMICINFO), ("MetronInfo.xml", METRONINFO)] + ) + with ( + zipfile.ZipFile(path) as archive, + patch("zipfile.ZipFile", side_effect=AssertionError("reopened")), + ): + assert read_open_zip_metadata(archive).metroninfo.payload == METRONINFO + + +def test_memory_factory_rejects_unknown_targets_and_growth_before_allocation() -> None: + from pullbox.core.archive_metadata import _MetadataFactory + + factory = _MetadataFactory(["MetronInfo.xml"]) + with pytest.raises(ArchiveError, match="Unexpected"): + factory.create("page.jpg") + writer = factory.create("MetronInfo.xml") + writer.write(b"ok") + with pytest.raises(ArchiveResourceLimitError): + writer.write(b"x" * MAX_METADATA_BYTES) + assert writer.size() == 2 + with pytest.raises(ArchiveResourceLimitError): + writer.seek(MAX_METADATA_BYTES + 1) + writer.seek(0) + assert writer.read() == b"ok" From c332d6cae5ff7fdf415f62c09e39d3826d7daa30 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 20:01:40 -0700 Subject: [PATCH 09/97] feat: persist metadata identities with guarded legacy backfill --- ...8k9l0m123_add_metadata_identity_storage.py | 130 +++++++ ...9l0m1n234_backfill_comicvine_identities.py | 192 ++++++++++ src/pullbox/models/__init__.py | 12 + src/pullbox/models/metadata_identity.py | 148 ++++++++ .../test_production_migration.py | 345 ++++++++++++++++++ .../test_production_storage.py | 172 +++++++++ tests/integration/test_migrations.py | 4 +- tests/unit/test_database_model_contracts.py | 3 + tests/unit/test_metadata_identity_models.py | 47 +++ 9 files changed, 1052 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py create mode 100644 alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py create mode 100644 src/pullbox/models/metadata_identity.py create mode 100644 tests/integration/metadata_identity/test_production_migration.py create mode 100644 tests/integration/metadata_identity/test_production_storage.py create mode 100644 tests/unit/test_metadata_identity_models.py diff --git a/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py b/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py new file mode 100644 index 00000000..2f4e7b9d --- /dev/null +++ b/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py @@ -0,0 +1,130 @@ +"""Add provider identity ownership and retained evidence history.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "p7j8k9l0m123" +down_revision = "o6i7j8k9l012" +branch_labels = None +depends_on = None + +_KINDS = (("series", "series"), ("issue", "issues"), ("story_arc", "story_arcs")) +_NAMESPACES = ("comicvine", "metron", "gcd", "locg") +_STATES = ("observed", "verified", "conflicted", "stale", "rejected") +_EVIDENCE = ( + "legacy_backfill", + "mylar_database", + "series_json", + "comicinfo_xml", + "metroninfo_xml", + "provider_result", + "provider_crosswalk", + "user_selection", + "migration", +) + + +def _enum(name: str, values: tuple[str, ...]) -> sa.Enum: + return sa.Enum(*values, name=name, native_enum=False, create_constraint=True) + + +def _decimal_id_check() -> str: + remaining = "external_id" + for digit in "0123456789": + remaining = f"replace({remaining}, '{digit}', '')" + return ( + "length(external_id) BETWEEN 1 AND 255 AND " + "substr(external_id, 1, 1) IN ('1','2','3','4','5','6','7','8','9') AND " + f"{remaining} = ''" + ) + + +def _claim_columns(kind: str, parent: str) -> list[sa.Column]: + return [ + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + f"{kind}_id", + sa.Integer, + sa.ForeignKey(f"{parent}.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("identity_namespace", _enum("identitynamespace", _NAMESPACES), nullable=False), + sa.Column("external_id", sa.String(255), nullable=False), + sa.Column( + "verification_state", _enum("identityverificationstate", _STATES), nullable=False + ), + sa.Column("evidence_kind", _enum("identityevidencekind", _EVIDENCE), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ] + + +def upgrade() -> None: + existing = set(sa.inspect(op.get_bind()).get_table_names()) + for kind, parent in _KINDS: + name = f"{kind}_external_identities" + if kind != "story_arc" and name not in existing: + op.create_table( + name, + *_claim_columns(kind, parent), + sa.Column("resource_url", sa.String(1000)), + sa.Column("evidence_locator", sa.Text), + sa.Column("verified_at", sa.DateTime(timezone=True)), + sa.Column("last_seen_at", sa.DateTime(timezone=True)), + sa.Column("revision", sa.Integer, server_default="1", nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("identity_namespace", "external_id", name=f"uq_{name}_owner"), + sa.UniqueConstraint( + f"{kind}_id", "identity_namespace", name=f"uq_{name}_namespace" + ), + sa.CheckConstraint( + "verification_state IN ('verified', 'stale', 'conflicted')", + name=f"ck_{name}_active_state", + ), + sa.CheckConstraint(_decimal_id_check(), name=f"ck_{name}_decimal_id"), + sa.CheckConstraint("revision > 0", name=f"ck_{name}_revision"), + ) + name = f"{kind}_identity_events" + if name not in existing: + op.create_table( + name, + *_claim_columns(kind, parent), + sa.Column("event_key", sa.String(64), nullable=False), + sa.Column("request_fingerprint", sa.String(64), nullable=False), + sa.Column("request_json", sa.Text, nullable=False), + sa.UniqueConstraint(f"{kind}_id", "event_key", name=f"uq_{name}_retry"), + sa.CheckConstraint(_decimal_id_check(), name=f"ck_{name}_decimal_id"), + sa.CheckConstraint("length(event_key) = 64", name=f"ck_{name}_event_key"), + sa.CheckConstraint( + "length(request_fingerprint) = 64", name=f"ck_{name}_fingerprint" + ), + sa.CheckConstraint( + "length(request_json) BETWEEN 2 AND 8192", name=f"ck_{name}_request" + ), + ) + # A SQLite DDL interruption can leave the table without its index. + indexes = {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(name)} + if f"ix_{name}_claim" not in indexes: + op.create_index( + f"ix_{name}_claim", name, [f"{kind}_id", "identity_namespace", "external_id", "id"] + ) + + +def downgrade() -> None: + names = [f"{kind}_identity_events" for kind, _ in _KINDS] + names += ["issue_external_identities", "series_external_identities"] + for name in names: + table = sa.table(name, sa.column("id")) + if op.get_bind().execute(sa.select(table.c.id).limit(1)).first() is not None: + raise RuntimeError( + "Cannot remove metadata identity storage while retained identity data exists." + ) + for name in names: + op.drop_table(name) diff --git a/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py b/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py new file mode 100644 index 00000000..f87a6680 --- /dev/null +++ b/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py @@ -0,0 +1,192 @@ +"""Preserve existing canonical ComicVine ownership in generic identity storage.""" + +import hashlib +import json + +import sqlalchemy as sa + +from alembic import op + +revision = "q8k9l0m1n234" +down_revision = "p7j8k9l0m123" +branch_labels = None +depends_on = None + +_PAGE_SIZE = 400 +_OPERATION_ID = "b69082d3-830e-4a72-9ad7-818c9d169381" +_KINDS = (("series", "series"), ("issue", "issues")) + + +def _json(value: dict) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _expected(kind: str, local_id: int, external_id: str) -> tuple[dict, dict]: + """Frozen v1 event envelope; migrations must not import evolving runtime policy.""" + origin = {"record_kind": kind, "record_id": local_id} + revision_digest = _digest(f"legacy_comicvine:{kind}:{local_id}:{external_id}") + slot = { + "version": 1, + "operation_id": _OPERATION_ID, + "entity_kind": kind, + "local_id": local_id, + "identity_namespace": "comicvine", + "action": "verify", + "origin": origin, + "evidence_revision": revision_digest, + } + payload = { + "version": 1, + "operation_id": _OPERATION_ID, + "local_id": local_id, + "identity": {"namespace": "comicvine", "entity_kind": kind, "external_id": external_id}, + "action": "verify", + "evidence_kind": "legacy_backfill", + "evidence_revision": revision_digest, + "origin": origin, + "actor": "migration", + "actor_user_id": None, + "review_revision": None, + } + request_json = _json(payload) + common = { + f"{kind}_id": local_id, + "identity_namespace": "comicvine", + "external_id": external_id, + "verification_state": "verified", + "evidence_kind": "legacy_backfill", + } + return ( + { + **common, + "resource_url": None, + "evidence_locator": _json(origin), + "verified_at": None, + "last_seen_at": None, + "revision": 1, + }, + { + **common, + "event_key": _digest(_json(slot)), + "request_fingerprint": _digest(request_json), + "request_json": request_json, + }, + ) + + +def _tables(kind: str, parent: str) -> tuple[sa.Table, sa.Table, sa.Table]: + metadata = sa.MetaData() + connection = op.get_bind() + return tuple( + sa.Table(name, metadata, autoload_with=connection) + for name in ( + parent, + f"{kind}_external_identities", + f"{kind}_identity_events", + ) + ) + + +def _valid_id(value: object) -> bool: + return type(value) is int and 0 < value < 2**63 + + +def _check_equal(row: sa.RowMapping, expected: dict) -> None: + if any(row[key] != value for key, value in expected.items()): + raise RuntimeError( + "Metadata identity data differs from its legacy backfill; " + "review before retry or downgrade." + ) + + +def upgrade() -> None: + connection = op.get_bind() + for kind, parent in _KINDS: + legacy, active, events = _tables(kind, parent) + target_key = f"{kind}_id" + cursor = 0 + while rows := connection.execute( + sa.select(legacy.c.id, legacy.c.comicvine_id) + .where(legacy.c.id > cursor) + .order_by(legacy.c.id) + .limit(_PAGE_SIZE) + ).all(): + cursor = rows[-1].id + valid = [row for row in rows if _valid_id(row.comicvine_id)] + if not valid: + continue + target_ids = [row.id for row in valid] + current = { + row[target_key]: row + for row in connection.execute( + sa.select(active).where( + active.c[target_key].in_(target_ids), + active.c.identity_namespace == "comicvine", + ) + ).mappings() + } + expected = [_expected(kind, row.id, str(row.comicvine_id)) for row in valid] + prior_events = { + row.event_key: row + for row in connection.execute( + sa.select(events).where( + events.c.event_key.in_([event["event_key"] for _, event in expected]), + events.c[target_key].in_(target_ids), + ) + ).mappings() + } + new_owners, new_events = [], [] + for owner, event in expected: + if owner[target_key] in current: + _check_equal(current[owner[target_key]], owner) + else: + new_owners.append(owner) + if event["event_key"] in prior_events: + _check_equal(prior_events[event["event_key"]], event) + else: + new_events.append(event) + if new_owners: + connection.execute(sa.insert(active), new_owners) + if new_events: + connection.execute(sa.insert(events), new_events) + + +def downgrade() -> None: + connection = op.get_bind() + arc_events = sa.table("story_arc_identity_events", sa.column("id")) + if connection.execute(sa.select(arc_events.c.id).limit(1)).first() is not None: + raise RuntimeError("Cannot discard retained Story Arc identity history during downgrade.") + tables = [] + # Preflight every row before deleting anything, including on SQLite. + for kind, parent in _KINDS: + legacy, active, events = _tables(kind, parent) + for table, expected_index in ((active, 0), (events, 1)): + tables.append(table) + cursor = 0 + while ( + rows := connection.execute( + sa.select(table, legacy.c.comicvine_id) + .select_from(table.outerjoin(legacy, table.c[f"{kind}_id"] == legacy.c.id)) + .where(table.c.id > cursor) + .order_by(table.c.id) + .limit(_PAGE_SIZE) + ) + .mappings() + .all() + ): + cursor = rows[-1]["id"] + for row in rows: + if not _valid_id(row["comicvine_id"]): + raise RuntimeError( + "Cannot discard metadata identity data without an equivalent legacy ID." + ) + expected = _expected(kind, row[f"{kind}_id"], str(row["comicvine_id"]))[ + expected_index + ] + _check_equal(row, expected) + for table in reversed(tables): + connection.execute(sa.delete(table)) diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index 4f16f6c6..2e98670f 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -60,6 +60,13 @@ MatchConfidence, ) from pullbox.models.matching_suggestion import MatchingSuggestion, SuggestionStatus +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, + StoryArcIdentityEvent, +) from pullbox.models.operation_progress import ( OperationProgress, OperationProgressState, @@ -169,6 +176,8 @@ "Issue", "IssueCatalogState", "IssueCreator", + "IssueExternalIdentity", + "IssueIdentityEvent", "IssueReaderState", "IssueStatus", "IssueStoryArc", @@ -196,11 +205,14 @@ "SearchLog", "SearchType", "Series", + "SeriesExternalIdentity", + "SeriesIdentityEvent", "SeriesStatus", "SeriesStatusOverride", "SeriesType", "StoryArc", "StoryArcExternalIdentity", + "StoryArcIdentityEvent", "StoryArcLifecycle", "StoryArcPlacement", "StoryArcPlacementMode", diff --git a/src/pullbox/models/metadata_identity.py b/src/pullbox/models/metadata_identity.py new file mode 100644 index 00000000..8f074403 --- /dev/null +++ b/src/pullbox/models/metadata_identity.py @@ -0,0 +1,148 @@ +"""Provider ownership and retained evidence, independent of provider configuration.""" + +from __future__ import annotations + +from datetime import datetime # noqa: TC003 - SQLAlchemy resolves mapped types at runtime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + CheckConstraint, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy import Enum as SQLAlchemyEnum +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.core.metadata_identity import IdentityEvidenceKind, IdentityNamespace +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime + +if TYPE_CHECKING: + import enum + + +def _stored_enum(kind: type[enum.Enum]) -> SQLAlchemyEnum: + return SQLAlchemyEnum( + kind, + native_enum=False, + create_constraint=True, + values_callable=lambda members: [member.value for member in members], + ) + + +def _decimal_id_check() -> str: + remaining = "external_id" + for digit in "0123456789": + remaining = f"replace({remaining}, '{digit}', '')" + return ( + "length(external_id) BETWEEN 1 AND 255 AND " + "substr(external_id, 1, 1) IN ('1','2','3','4','5','6','7','8','9') AND " + f"{remaining} = ''" + ) + + +class _ClaimFields: + identity_namespace: Mapped[IdentityNamespace] = mapped_column( + _stored_enum(IdentityNamespace), nullable=False + ) + external_id: Mapped[str] = mapped_column(String(255), nullable=False) + verification_state: Mapped[IdentityVerificationState] = mapped_column( + _stored_enum(IdentityVerificationState), nullable=False + ) + evidence_kind: Mapped[IdentityEvidenceKind] = mapped_column( + _stored_enum(IdentityEvidenceKind), nullable=False + ) + + +class _OwnershipFields(_ClaimFields): + resource_url: Mapped[str | None] = mapped_column(String(1000)) + evidence_locator: Mapped[str | None] = mapped_column(Text) + verified_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + revision: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False) + + +def _ownership_constraints(kind: str) -> tuple[UniqueConstraint | CheckConstraint, ...]: + prefix = f"{kind}_external_identities" + return ( + UniqueConstraint("identity_namespace", "external_id", name=f"uq_{prefix}_owner"), + UniqueConstraint(f"{kind}_id", "identity_namespace", name=f"uq_{prefix}_namespace"), + CheckConstraint( + "verification_state IN ('verified', 'stale', 'conflicted')", + name=f"ck_{prefix}_active_state", + ), + CheckConstraint(_decimal_id_check(), name=f"ck_{prefix}_decimal_id"), + CheckConstraint("revision > 0", name=f"ck_{prefix}_revision"), + ) + + +class SeriesExternalIdentity(Base, IdentityMixin, TimestampMixin, _OwnershipFields): + __tablename__ = "series_external_identities" + __table_args__ = _ownership_constraints("series") + + series_id: Mapped[int] = mapped_column( + ForeignKey("series.id", ondelete="CASCADE"), nullable=False + ) + + +class IssueExternalIdentity(Base, IdentityMixin, TimestampMixin, _OwnershipFields): + __tablename__ = "issue_external_identities" + __table_args__ = _ownership_constraints("issue") + + issue_id: Mapped[int] = mapped_column( + ForeignKey("issues.id", ondelete="CASCADE"), nullable=False + ) + + +class _EventFields(_ClaimFields): + # Evidence events have no mutable timestamp; writers append new decisions. + event_key: Mapped[str] = mapped_column(String(64), nullable=False) + request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + request_json: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, server_default=func.now(), nullable=False + ) + + +def _event_constraints(kind: str) -> tuple[UniqueConstraint | CheckConstraint | Index, ...]: + prefix = f"{kind}_identity_events" + return ( + UniqueConstraint(f"{kind}_id", "event_key", name=f"uq_{prefix}_retry"), + CheckConstraint(_decimal_id_check(), name=f"ck_{prefix}_decimal_id"), + CheckConstraint("length(event_key) = 64", name=f"ck_{prefix}_event_key"), + CheckConstraint("length(request_fingerprint) = 64", name=f"ck_{prefix}_fingerprint"), + CheckConstraint("length(request_json) BETWEEN 2 AND 8192", name=f"ck_{prefix}_request"), + Index(f"ix_{prefix}_claim", f"{kind}_id", "identity_namespace", "external_id", "id"), + ) + + +class SeriesIdentityEvent(Base, IdentityMixin, _EventFields): + __tablename__ = "series_identity_events" + __table_args__ = _event_constraints("series") + + series_id: Mapped[int] = mapped_column( + ForeignKey("series.id", ondelete="CASCADE"), nullable=False + ) + + +class IssueIdentityEvent(Base, IdentityMixin, _EventFields): + __tablename__ = "issue_identity_events" + __table_args__ = _event_constraints("issue") + + issue_id: Mapped[int] = mapped_column( + ForeignKey("issues.id", ondelete="CASCADE"), nullable=False + ) + + +class StoryArcIdentityEvent(Base, IdentityMixin, _EventFields): + __tablename__ = "story_arc_identity_events" + __table_args__ = _event_constraints("story_arc") + + story_arc_id: Mapped[int] = mapped_column( + ForeignKey("story_arcs.id", ondelete="CASCADE"), nullable=False + ) diff --git a/tests/integration/metadata_identity/test_production_migration.py b/tests/integration/metadata_identity/test_production_migration.py new file mode 100644 index 00000000..1de90567 --- /dev/null +++ b/tests/integration/metadata_identity/test_production_migration.py @@ -0,0 +1,345 @@ +"""Run actual identity revisions against disposable predecessor schemas.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from uuid import UUID + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import Column, Integer, MetaData, Table, event, insert, inspect, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventEvidence, + IdentityEventRequest, + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + prepare_identity_event, +) +from pullbox.core.metadata_identity_state import IdentityVerificationAction +from pullbox.models import Base, Issue, Series, StoryArc, StoryArcExternalIdentity + +_VERSIONS = Path(__file__).resolve().parents[3] / "alembic" / "versions" +_NAMES = ( + "series_external_identities", + "issue_external_identities", + "series_identity_events", + "issue_identity_events", + "story_arc_identity_events", +) + + +def _revision(name, connection): + spec = importlib.util.spec_from_file_location(name, _VERSIONS / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +def _schema(connection): + return _revision("p7j8k9l0m123_add_metadata_identity_storage", connection) + + +def _data(connection): + return _revision("q8k9l0m1n234_backfill_comicvine_identities", connection) + + +async def _predecessor(engine): + async with engine.begin() as connection: + for name in reversed(_NAMES): + await connection.run_sync(Base.metadata.tables[name].drop) + + +async def _seed(factory): + async with factory.begin() as session: + series = [ + Series(title=f"Legacy {n}", sort_title=f"legacy {n}", comicvine_id=value) + for n, value in enumerate([42, None, 0, -10]) + ] + session.add_all(series) + await session.flush() + issues = [ + Issue(series_id=row.id, issue_number=1, comicvine_id=value) + for row, value in zip(series, [142, None, 0, -20], strict=True) + ] + session.add_all(issues) + arc = StoryArc(name="Preserved arc", comicvine_id=77) + session.add(arc) + await session.flush() + session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id="77" + ), + StoryArcExternalIdentity( + story_arc_id=arc.id, + source="mylar3", + namespace="source-a", + external_id="arbitrary-scope", + ), + ] + ) + return series, issues + + +async def test_schema_migration_matches_production_models_and_is_retry_safe(identity_probe_db): + engine, _, _ = identity_probe_db + await _predecessor(engine) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + names = await connection.run_sync(lambda conn: inspect(conn).get_table_names()) + assert set(_NAMES) <= set(names) + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + + def compare(conn): + expected = MetaData() + for parent in ("series", "issues", "story_arcs"): + Table(parent, expected, Column("id", Integer, primary_key=True)) + for name in _NAMES: + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + conn, + opts={ + "include_object": lambda obj, name, type_, reflected, compare_to: ( + name in _NAMES if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + return compare_metadata(context, expected) + + assert await connection.run_sync(compare) == [] + await connection.run_sync(lambda conn: _schema(conn).downgrade()) + names = await connection.run_sync(lambda conn: inspect(conn).get_table_names()) + assert not set(_NAMES) & set(names) + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + + +async def test_backfill_round_trip_preserves_legacy_values_and_scoped_arcs(identity_probe_db): + engine, factory, _ = identity_probe_db + await _seed(factory) + await _predecessor(engine) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + await connection.run_sync(lambda conn: _data(conn).upgrade()) + for kind, expected in (("series", "42"), ("issue", "142")): + table = Base.metadata.tables[f"{kind}_external_identities"] + rows = (await connection.execute(select(table))).all() + assert len(rows) == 1 + assert rows[0].external_id == expected + assert rows[0].verification_state == "verified" + assert rows[0].evidence_kind == "legacy_backfill" + assert rows[0].verified_at is None # No provider re-verification was performed. + assert rows[0].last_seen_at is None + history = Base.metadata.tables[f"{kind}_identity_events"] + event = (await connection.execute(select(history))).one() + payload = json.loads(event.request_json) + assert payload["actor"] == "migration" + assert payload["origin"]["record_kind"] == kind + # Frozen migration payload must match the versioned production retry contract. + request = IdentityEventRequest( + UUID(payload["operation_id"]), + payload["local_id"], + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind(kind), expected + ), + IdentityEvidenceKind.LEGACY_BACKFILL, + ), + payload["evidence_revision"], + IdentityEvidenceLocator(IdentityEvidenceRecordKind(kind), payload["local_id"]), + ), + actor=IdentityEventActor.MIGRATION, + ) + prepared = prepare_identity_event(request) + assert (event.event_key, event.request_fingerprint, event.request_json) == ( + prepared.event_key, + prepared.request_fingerprint, + prepared.request_json, + ) + await connection.run_sync(lambda conn: _data(conn).upgrade()) + assert ( + len( + ( + await connection.execute(select(Base.metadata.tables["series_identity_events"])) + ).all() + ) + == 1 + ) + await connection.run_sync(lambda conn: _data(conn).downgrade()) + await connection.run_sync(lambda conn: _schema(conn).downgrade()) + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + await connection.run_sync(lambda conn: _data(conn).upgrade()) + async with factory() as session: + assert list(await session.scalars(select(Series.comicvine_id).order_by(Series.id))) == [ + 42, + None, + 0, + -10, + ] + assert list(await session.scalars(select(Issue.comicvine_id).order_by(Issue.id))) == [ + 142, + None, + 0, + -20, + ] + rows = ( + await session.execute( + select( + StoryArcExternalIdentity.source, StoryArcExternalIdentity.external_id + ).order_by(StoryArcExternalIdentity.id) + ) + ).all() + assert rows == [("comicvine", "77"), ("mylar3", "arbitrary-scope")] + + +@pytest.mark.parametrize( + "change", ["new_provider", "review_history", "changed_owner", "changed_state"] +) +async def test_downgrade_refuses_to_discard_nonreconstructible_identity_data( + identity_probe_db, change +): + engine, factory, _ = identity_probe_db + series, _ = await _seed(factory) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _data(conn).upgrade()) + active = Base.metadata.tables["series_external_identities"] + history = Base.metadata.tables["series_identity_events"] + if change == "new_provider": + await connection.execute( + insert(active).values( + series_id=series[0].id, + identity_namespace="metron", + external_id="90", + verification_state="verified", + evidence_kind="provider_result", + ) + ) + elif change == "review_history": + await connection.execute(update(history).values(evidence_kind="user_selection")) + elif change == "changed_owner": + await connection.execute( + update(Series).where(Series.id == series[0].id).values(comicvine_id=45) + ) + else: + await connection.execute(update(active).values(verification_state="conflicted")) + async with engine.begin() as connection: + with pytest.raises(RuntimeError, match="identity"): + await connection.run_sync(lambda conn: _data(conn).downgrade()) + assert len((await connection.execute(select(active))).all()) >= 1 + assert len((await connection.execute(select(history))).all()) == 1 + + +async def test_backfill_is_bounded_and_rolls_back_without_changing_legacy(identity_probe_db): + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + session.add_all( + [ + Series(title=f"Page {n}", sort_title=f"page {n}", comicvine_id=n + 1) + for n in range(1003) + ] + ) + batches = [] + + def record_batch(_conn, _cursor, statement, parameters, _context, executemany): + if executemany and statement.startswith("INSERT INTO series_"): + batches.append(len(parameters)) + + event.listen(engine.sync_engine, "before_cursor_execute", record_batch) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.run_sync(lambda conn: _data(conn).upgrade()) + table = Base.metadata.tables["series_external_identities"] + assert len((await connection.execute(select(table))).all()) == 1003 + await transaction.rollback() + assert (await connection.execute(select(table))).all() == [] + assert len((await connection.execute(select(Series.id))).all()) == 1003 + assert batches and max(batches) <= 400 # Two IN clauses stay below SQLite's 999-bind floor. + finally: + event.remove(engine.sync_engine, "before_cursor_execute", record_batch) + + +@pytest.mark.parametrize("case", ["ownership", "parent", "state", "numeric", "retry"]) +async def test_actual_migration_constraints_are_enforced(identity_probe_db, case): + engine, factory, _ = identity_probe_db + series, _ = await _seed(factory) + await _predecessor(engine) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + await connection.run_sync(lambda conn: _data(conn).upgrade()) + active = Base.metadata.tables["series_external_identities"] + history = Base.metadata.tables["series_identity_events"] + values = dict( + series_id=series[1].id, + identity_namespace="comicvine", + external_id="43", + verification_state="verified", + evidence_kind="provider_result", + ) + table = active + if case == "ownership": + values["external_id"] = "42" + elif case == "parent": + values["series_id"] = 999999 + elif case == "state": + values["verification_state"] = "rejected" + elif case == "numeric": + values["external_id"] = "0043" + else: + async with engine.connect() as connection: + values = dict((await connection.execute(select(history))).mappings().one()) + values.pop("id") + table = history + with pytest.raises(IntegrityError): + async with engine.begin() as connection: + await connection.execute(insert(table).values(values)) + + +async def test_schema_retry_recovers_missing_index_and_refuses_nonempty_downgrade( + identity_probe_db, +): + engine, factory, _ = identity_probe_db + await _seed(factory) + await _predecessor(engine) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + await connection.run_sync( + lambda conn: _schema(conn).op.drop_index( + "ix_series_identity_events_claim", table_name="series_identity_events" + ) + ) + await connection.run_sync(lambda conn: _schema(conn).upgrade()) + indexes = await connection.run_sync( + lambda conn: inspect(conn).get_indexes("series_identity_events") + ) + assert "ix_series_identity_events_claim" in {item["name"] for item in indexes} + await connection.run_sync(lambda conn: _data(conn).upgrade()) + with pytest.raises(RuntimeError, match="identity"): + await connection.run_sync(lambda conn: _schema(conn).downgrade()) + assert ( + len( + ( + await connection.execute( + select(Base.metadata.tables["series_external_identities"]) + ) + ).all() + ) + == 1 + ) diff --git a/tests/integration/metadata_identity/test_production_storage.py b/tests/integration/metadata_identity/test_production_storage.py new file mode 100644 index 00000000..be42a7d0 --- /dev/null +++ b/tests/integration/metadata_identity/test_production_storage.py @@ -0,0 +1,172 @@ +"""Enforce production identity storage on both supported database engines.""" + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import delete, func, insert, select +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.metadata_identity_events import prepare_identity_event +from pullbox.models import Base, Issue, Series, StoryArc +from tests.fixtures.metadata_identity_events import identity_event_request + + +def _table(kind, history=False): + name = f"{kind}_{'identity_events' if history else 'external_identities'}" + assert name in Base.metadata.tables, "Production identity storage must be registered" + return Base.metadata.tables[name] + + +async def _parents(factory, kind): + async with factory.begin() as session: + series = [Series(title=f"Identity {n}", sort_title=f"identity {n}") for n in range(2)] + session.add_all(series) + await session.flush() + if kind == "series": + return [row.id for row in series] + targets = ( + [Issue(series_id=row.id, issue_number=1) for row in series] + if kind == "issue" + else [StoryArc(name=f"Arc {n}") for n in range(2)] + ) + session.add_all(targets) + await session.flush() + return [row.id for row in targets] + + +def _values(kind, target, **overrides): + return { + f"{kind}_id": target, + "identity_namespace": "comicvine", + "external_id": "42", + "verification_state": "verified", + "evidence_kind": "legacy_backfill", + **overrides, + } + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +async def test_production_all_namespaces_have_independent_ownership(identity_probe_db, kind): + _, factory, _ = identity_probe_db + table = _table(kind) + target, _other = await _parents(factory, kind) + async with factory.begin() as session: + for namespace in ("comicvine", "metron", "gcd", "locg"): + await session.execute( + insert(table).values(_values(kind, target, identity_namespace=namespace)) + ) + async with factory() as session: + rows = (await session.execute(select(table))).all() + assert len(rows) == 4 + assert all(row.revision == 1 for row in rows) + assert all(row.created_at.utcoffset().total_seconds() == 0 for row in rows) + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +@pytest.mark.parametrize( + "case", + [ + "other_owner", + "other_id", + "missing_parent", + "observed", + "rejected", + "bad_state", + "bad_namespace", + "bad_evidence", + "zero_revision", + "empty_id", + "padded_id", + "invalid_id", + ], +) +async def test_production_constraints_reject_invalid_active_ownership( + identity_probe_db, kind, case +): + _, factory, _ = identity_probe_db + table = _table(kind) + first, second = await _parents(factory, kind) + async with factory.begin() as session: + await session.execute(insert(table).values(_values(kind, first))) + changes = { + "other_owner": {f"{kind}_id": second}, + "other_id": {"external_id": "43"}, + "missing_parent": {f"{kind}_id": 999999, "external_id": "43"}, + "observed": {"verification_state": "observed"}, + "rejected": {"verification_state": "rejected"}, + "bad_state": {"verification_state": "mystery"}, + "bad_namespace": {"identity_namespace": "unknown"}, + "bad_evidence": {"evidence_kind": "unknown"}, + "zero_revision": {"revision": 0}, + "empty_id": {"external_id": ""}, + "padded_id": {"external_id": "0042"}, + "invalid_id": {"external_id": "42x"}, + }[case] + # Remove the valid row for value checks so uniqueness cannot mask the intended constraint. + if case not in {"other_owner", "other_id", "missing_parent"}: + async with factory.begin() as session: + await session.execute(delete(table)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(insert(table).values(_values(kind, first, **changes))) + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +async def test_production_active_owner_race_and_parent_cascade(identity_probe_db, kind): + _, factory, _ = identity_probe_db + table = _table(kind) + targets = await _parents(factory, kind) + + async def attach(target): + try: + async with factory.begin() as session: + await session.execute( + insert(table).values(_values(kind, target, verification_state="stale")) + ) + return "attached" + except IntegrityError: + return "conflict" + + assert sorted(await asyncio.gather(*(attach(target) for target in targets))) == [ + "attached", + "conflict", + ] + async with factory.begin() as session: + model = Series if kind == "series" else Issue + await session.execute(delete(model)) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(table)) == 0 + + +@pytest.mark.parametrize("kind", ["series", "issue", "story_arc"]) +async def test_production_history_retains_rejections_and_replay_scope(identity_probe_db, kind): + _, factory, _ = identity_probe_db + table = _table(kind, history=True) + first, second = await _parents(factory, kind) + prepared = prepare_identity_event(identity_event_request(MetadataEntityKind(kind), first)) + payload = _values( + kind, + first, + verification_state="rejected", + event_key=prepared.event_key, + request_fingerprint=prepared.request_fingerprint, + request_json=prepared.request_json, + ) + async with factory.begin() as session: + await session.execute(insert(table).values(payload)) + await session.execute(insert(table).values({**payload, f"{kind}_id": second})) + if kind != "story_arc": + active = _table(kind) + await session.execute(insert(active).values(_values(kind, first))) + await session.execute(delete(active)) + assert await session.scalar(select(func.count()).select_from(table)) == 2 + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(insert(table).values(payload)) + async with factory.begin() as session: + model = {"series": Series, "issue": Issue, "story_arc": StoryArc}[kind] + await session.execute(delete(model)) + assert await session.scalar(select(func.count()).select_from(table)) == 0 diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index fd192933..250f5caa 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,9 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["o6i7j8k9l012"] + assert script.get_heads() == ["q8k9l0m1n234"] + assert script.get_revision("q8k9l0m1n234").down_revision == "p7j8k9l0m123" + assert script.get_revision("p7j8k9l0m123").down_revision == "o6i7j8k9l012" assert script.get_revision("o6i7j8k9l012").down_revision == "n5h6i7j8k901" assert script.get_revision("n5h6i7j8k901").down_revision == _IMPORT_JOB_ARCHIVE_REVISION command.upgrade(cfg, _IMPORT_JOB_ARCHIVE_REVISION) diff --git a/tests/unit/test_database_model_contracts.py b/tests/unit/test_database_model_contracts.py index 77436a2b..aaafb73f 100644 --- a/tests/unit/test_database_model_contracts.py +++ b/tests/unit/test_database_model_contracts.py @@ -30,6 +30,9 @@ "HealthCheckResult", # health sample time is stored in checked_at "ImportJobLog", # log event time is stored in logged_at "IssueCreator", # association table + "IssueIdentityEvent", # retained evidence has created_at only, never a mutable timestamp + "SeriesIdentityEvent", # retained evidence has created_at only, never a mutable timestamp + "StoryArcIdentityEvent", # retained evidence has created_at only, never a mutable timestamp "SystemConfig", # key-value config tracks updated_at only "UtilityJob", # utility schema stores lifecycle timestamps as TEXT "UtilityJobItem", # utility schema stores item lifecycle timestamps as TEXT diff --git a/tests/unit/test_metadata_identity_models.py b/tests/unit/test_metadata_identity_models.py new file mode 100644 index 00000000..075387c4 --- /dev/null +++ b/tests/unit/test_metadata_identity_models.py @@ -0,0 +1,47 @@ +"""Production identity ownership uses concrete parents and retained history.""" + +import pytest + +from pullbox.models import Base + + +@pytest.mark.parametrize("kind,parent", [("series", "series"), ("issue", "issues")]) +def test_active_identity_tables_are_registered_with_real_foreign_keys(kind, parent): + name = f"{kind}_external_identities" + assert name in Base.metadata.tables + table = Base.metadata.tables[name] + assert {fk.target_fullname for fk in table.foreign_keys} == {f"{parent}.id"} + assert all(fk.ondelete == "CASCADE" for fk in table.foreign_keys) + assert { + "identity_namespace", + "external_id", + "verification_state", + "evidence_kind", + "resource_url", + "evidence_locator", + "verified_at", + "last_seen_at", + "revision", + "created_at", + "updated_at", + } <= set(table.c.keys()) + + +@pytest.mark.parametrize( + "kind,parent", [("series", "series"), ("issue", "issues"), ("story_arc", "story_arcs")] +) +def test_history_belongs_to_parent_not_attachment_or_provider_configuration(kind, parent): + name = f"{kind}_identity_events" + assert name in Base.metadata.tables + table = Base.metadata.tables[name] + assert {fk.target_fullname for fk in table.foreign_keys} == {f"{parent}.id"} + assert all(fk.ondelete == "CASCADE" for fk in table.foreign_keys) + assert { + "event_key", + "request_fingerprint", + "request_json", + "verification_state", + "evidence_kind", + "created_at", + } <= set(table.c.keys()) + assert "updated_at" not in table.c From 1963fc247e6e4a547bc121229b708a686d7089e9 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 20:40:59 -0700 Subject: [PATCH 10/97] feat: attach metadata identities atomically during arc adoption --- src/pullbox/core/metadata_identity_events.py | 12 + .../services/metadata_identity_attachment.py | 456 +++++++++++++++++ .../services/story_arc_catalog_persistence.py | 66 +++ .../test_attachment_service.py | 466 ++++++++++++++++++ .../test_metadata_identity_parent_evidence.py | 47 ++ tests/unit/test_story_arc_catalog.py | 67 +++ 6 files changed, 1114 insertions(+) create mode 100644 src/pullbox/services/metadata_identity_attachment.py create mode 100644 tests/integration/metadata_identity/test_attachment_service.py create mode 100644 tests/unit/test_metadata_identity_parent_evidence.py diff --git a/src/pullbox/core/metadata_identity_events.py b/src/pullbox/core/metadata_identity_events.py index 540e0691..e25cf840 100644 --- a/src/pullbox/core/metadata_identity_events.py +++ b/src/pullbox/core/metadata_identity_events.py @@ -12,6 +12,7 @@ ExactIdentityEvidence, ExternalIdentityRef, IdentityEvidenceKind, + MetadataEntityKind, ) from pullbox.core.metadata_identity_state import IdentityVerificationAction @@ -54,6 +55,7 @@ class IdentityEventEvidence: revision: str locator: IdentityEvidenceLocator | None = None source_identity: ExternalIdentityRef | None = None + parent_identity: ExternalIdentityRef | None = None def __post_init__(self) -> None: if not isinstance(self.claim, ExactIdentityEvidence) or not isinstance( @@ -62,6 +64,13 @@ def __post_init__(self) -> None: raise ValueError("Invalid identity evidence claim") if not _is_digest(self.revision): raise ValueError("Invalid identity evidence revision digest") + if self.parent_identity is not None and ( + not isinstance(self.parent_identity, ExternalIdentityRef) + or self.claim.identity.entity_kind is not MetadataEntityKind.ISSUE + or self.parent_identity.entity_kind is not MetadataEntityKind.SERIES + or self.parent_identity.namespace != self.claim.identity.namespace + ): + raise ValueError("Invalid issue parent identity evidence") provider_kinds = { IdentityEvidenceKind.PROVIDER_RESULT, IdentityEvidenceKind.PROVIDER_CROSSWALK, @@ -186,6 +195,9 @@ def prepare_identity_event(request: IdentityEventRequest) -> PreparedIdentityEve "actor_user_id": request.actor_user_id, "review_revision": request.review_revision, } + # Keep existing v1 requests byte-identical when no parent was recorded. + if evidence.parent_identity is not None: + payload["parent_identity"] = _identity_payload(evidence.parent_identity) serialized = _canonical_json(payload) return PreparedIdentityEvent(_digest(_canonical_json(slot)), _digest(serialized), serialized) diff --git a/src/pullbox/services/metadata_identity_attachment.py b/src/pullbox/services/metadata_identity_attachment.py new file mode 100644 index 00000000..b0a6c894 --- /dev/null +++ b/src/pullbox/services/metadata_identity_attachment.py @@ -0,0 +1,456 @@ +"""Transactional verified-identity attachment for validated internal adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from itertools import groupby +from typing import TYPE_CHECKING + +from sqlalchemy import false, func, insert, or_, select, tuple_, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventReplayConflictError, + prepare_identity_event, + validate_identity_event_replay, +) +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, + IdentityVerificationState, +) +from pullbox.models import Base, Issue, Series +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import RowMapping + from sqlalchemy.ext.asyncio import AsyncSession + + from pullbox.core.metadata_identity_events import IdentityEventRequest + +_BATCH_SIZE = 200 + + +class IdentityAttachmentConflictError(ValueError): + """Identity ownership or parent evidence disagrees with the local target.""" + + +@dataclass(frozen=True) +class IdentityAttachmentReceipt: + event_id: int + replayed: bool + + +async def attach_verified_identities( + session: AsyncSession, requests: Sequence[IdentityEventRequest] +) -> list[IdentityAttachmentReceipt]: + """Attach adapter-validated exact evidence in the caller's transaction. + + This is not a transport API or an authorization boundary. Adapters must + establish evidence trust before calling it. Explicit user decisions use a + separate review path; this path must never override their retained history. + """ + if not requests: + return [] + unique: dict[str, IdentityEventRequest] = {} + for request in requests: + identity = request.evidence.claim.identity + if ( + request.action is not IdentityVerificationAction.VERIFY + or request.actor is not IdentityEventActor.AUTOMATION + or identity.entity_kind not in {MetadataEntityKind.SERIES, MetadataEntityKind.ISSUE} + ): + raise ValueError("This attachment path accepts validated automatic verification only") + if ( + identity.entity_kind is MetadataEntityKind.ISSUE + and request.evidence.parent_identity is None + ): + raise IdentityAttachmentConflictError( + "Verified issue attachment requires exact parent evidence" + ) + if identity.namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63: + raise IdentityAttachmentConflictError( + "ComicVine identity exceeds the compatibility ID range" + ) + prepared = prepare_identity_event(request) + previous = unique.setdefault(prepared.event_key, request) + if prepare_identity_event(previous).request_json != prepared.request_json: + raise IdentityEventReplayConflictError( + "One batch reuses an identity operation inconsistently" + ) + + # SQLite's legacy deferred BEGIN must enclose SAVEPOINT, even for existing + # parents with no pending ORM writes. This also serializes SQLite writers. + if session.get_bind().dialect.name == "sqlite": + await session.execute( + update(Series).where(false()).values(comicvine_id=Series.comicvine_id) + ) + receipts: dict[str, IdentityAttachmentReceipt] = {} + ordered = sorted(unique.values(), key=_sort_key) + try: + async with session.begin_nested(): + await _lock_parent_graph(session, ordered) + for kind, members in groupby( + ordered, key=lambda item: item.evidence.claim.identity.entity_kind + ): + group = list(members) + for offset in range(0, len(group), _BATCH_SIZE): + receipts.update( + await _attach_batch(session, kind, group[offset : offset + _BATCH_SIZE]) + ) + except IntegrityError as exc: + raise IdentityAttachmentConflictError( + "Identity ownership changed; reload before retrying" + ) from exc + return [receipts[prepare_identity_event(request).event_key] for request in requests] + + +def _sort_key(request: IdentityEventRequest) -> tuple[int, int, str, str]: + identity = request.evidence.claim.identity + return ( + 0 if identity.entity_kind is MetadataEntityKind.SERIES else 1, + request.local_id, + identity.namespace.value, + identity.external_id, + ) + + +async def _lock_parent_graph(session: AsyncSession, requests: list[IdentityEventRequest]) -> None: + """Acquire every parent before any child, across the whole batch sequence.""" + series_ids = { + request.local_id + for request in requests + if request.evidence.claim.identity.entity_kind is MetadataEntityKind.SERIES + } + issue_ids = sorted( + { + request.local_id + for request in requests + if request.evidence.claim.identity.entity_kind is MetadataEntityKind.ISSUE + } + ) + expected_parents: dict[int, int] = {} + for offset in range(0, len(issue_ids), _BATCH_SIZE): + expected_parents.update( + ( + await session.execute( + select(Issue.id, Issue.series_id).where( + Issue.id.in_(issue_ids[offset : offset + _BATCH_SIZE]) + ) + ) + ) + .tuples() + .all() + ) + series_ids.update(expected_parents.values()) + ordered_parents = sorted(series_ids) + for offset in range(0, len(ordered_parents), _BATCH_SIZE): + await session.execute( + select(Series.id) + .where(Series.id.in_(ordered_parents[offset : offset + _BATCH_SIZE])) + .order_by(Series.id) + .with_for_update() + ) + for offset in range(0, len(issue_ids), _BATCH_SIZE): + rows = await session.execute( + select(Issue.id, Issue.series_id) + .where(Issue.id.in_(issue_ids[offset : offset + _BATCH_SIZE])) + .order_by(Issue.id) + .with_for_update() + ) + if any(expected_parents.get(local_id) != parent_id for local_id, parent_id in rows): + raise IdentityAttachmentConflictError("Issue parent changed during verification") + + +async def _locked_targets( + session: AsyncSession, kind: MetadataEntityKind, target_ids: list[int] +) -> dict[int, RowMapping]: + model = Series if kind is MetadataEntityKind.SERIES else Issue + table = Base.metadata.tables[model.__tablename__] + columns = [table.c.id, table.c.comicvine_id] + if kind is MetadataEntityKind.ISSUE: + columns.append(table.c.series_id) + rows = ( + ( + await session.execute( + select(*columns).where(table.c.id.in_(target_ids)).order_by(table.c.id) + ) + ) + .mappings() + .all() + ) + targets = {int(row.id): row for row in rows} + if len(targets) != len(set(target_ids)): + raise IdentityAttachmentConflictError("Identity target no longer exists") + return targets + + +async def _attach_batch( + session: AsyncSession, kind: MetadataEntityKind, requests: list[IdentityEventRequest] +) -> dict[str, IdentityAttachmentReceipt]: + target_key = f"{kind.value}_id" + active = Base.metadata.tables[f"{kind.value}_external_identities"] + history = Base.metadata.tables[f"{kind.value}_identity_events"] + targets = await _locked_targets( + session, kind, sorted({request.local_id for request in requests}) + ) + prepared = { + prepare_identity_event(request).event_key: prepare_identity_event(request) + for request in requests + } + stored = ( + ( + await session.execute( + select(history).where( + tuple_(history.c[target_key], history.c.event_key).in_( + [ + (request.local_id, prepare_identity_event(request).event_key) + for request in requests + ] + ) + ) + ) + ) + .mappings() + .all() + ) + receipts: dict[str, IdentityAttachmentReceipt] = {} + for row in stored: + payload = prepared[row.event_key] + validate_identity_event_replay( + payload, event_key=row.event_key, request_fingerprint=row.request_fingerprint + ) + if ( + row.request_json != payload.request_json + or row.verification_state is not IdentityVerificationState.VERIFIED + ): + raise IdentityEventReplayConflictError("Stored identity request is inconsistent") + receipts[row.event_key] = IdentityAttachmentReceipt(row.id, True) + fresh = [ + request for request in requests if prepare_identity_event(request).event_key not in receipts + ] + if not fresh: + return receipts + + active_rows = ( + ( + await session.execute( + select(active).where( + or_( + active.c[target_key].in_([request.local_id for request in fresh]), + tuple_(active.c.identity_namespace, active.c.external_id).in_( + [ + ( + request.evidence.claim.identity.namespace, + request.evidence.claim.identity.external_id, + ) + for request in fresh + ] + ), + ) + ) + ) + ) + .mappings() + .all() + ) + by_target = {(row[target_key], row.identity_namespace): row for row in active_rows} + by_external = {(row.identity_namespace, row.external_id): row for row in active_rows} + claim_columns = (history.c[target_key], history.c.identity_namespace, history.c.external_id) + latest_ids = ( + select(func.max(history.c.id)) + .where( + tuple_(*claim_columns).in_( + [ + ( + request.local_id, + request.evidence.claim.identity.namespace, + request.evidence.claim.identity.external_id, + ) + for request in fresh + ] + ) + ) + .group_by(*claim_columns) + ) + decisions = { + (row[target_key], row.identity_namespace, row.external_id): row.verification_state + for row in ( + await session.execute(select(history).where(history.c.id.in_(latest_ids))) + ).mappings() + } + await _validate_legacy_owners(session, kind, fresh, targets) + if kind is MetadataEntityKind.ISSUE: + await _validate_issue_parents(session, fresh, targets) + + now = datetime.now(UTC) + creates: dict[tuple[int, IdentityNamespace], dict[str, object]] = {} + changes: dict[tuple[int, IdentityNamespace], dict[str, object]] = {} + revisions: dict[tuple[int, IdentityNamespace], int] = {} + events: list[dict[str, object]] = [] + legacy_changes: dict[int, dict[str, int]] = {} + seen_targets: dict[tuple[int, IdentityNamespace], str] = {} + seen_owners: dict[tuple[IdentityNamespace, str], int] = {} + for request in fresh: + identity = request.evidence.claim.identity + namespace, external_id = identity.namespace, identity.external_id + target = (request.local_id, namespace) + external = (namespace, external_id) + if ( + seen_targets.setdefault(target, external_id) != external_id + or seen_owners.setdefault(external, request.local_id) != request.local_id + ): + raise IdentityAttachmentConflictError("Batch contains conflicting identity ownership") + existing, owner = by_target.get(target), by_external.get(external) + if (existing is not None and existing.external_id != external_id) or ( + owner is not None and owner[target_key] != request.local_id + ): + raise IdentityAttachmentConflictError("Provider identity already has a different owner") + if ( + existing is not None + and existing.verification_state is IdentityVerificationState.CONFLICTED + ) or decisions.get((*target, external_id)) in { + IdentityVerificationState.CONFLICTED, + IdentityVerificationState.REJECTED, + }: + raise IdentityReviewRequiredError("This identity requires explicit review") + payload = prepare_identity_event(request) + values: dict[str, object] = { + "verification_state": IdentityVerificationState.VERIFIED, + "evidence_kind": request.evidence.claim.evidence_kind, + "evidence_locator": payload.request_json, + "verified_at": now, + "last_seen_at": now, + } + revisions[target] = ( + revisions.get(target, existing.revision if existing is not None else 0) + 1 + ) + values["revision"] = revisions[target] + if existing is None: + creates[target] = { + **values, + target_key: request.local_id, + "identity_namespace": namespace, + "external_id": external_id, + } + else: + changes[target] = {**values, "id": existing.id} + if ( + namespace is IdentityNamespace.COMICVINE + and targets[request.local_id].comicvine_id is None + ): + legacy_changes[request.local_id] = { + "id": request.local_id, + "comicvine_id": int(external_id), + } + events.append( + { + target_key: request.local_id, + "identity_namespace": namespace, + "external_id": external_id, + "verification_state": IdentityVerificationState.VERIFIED, + "evidence_kind": request.evidence.claim.evidence_kind, + "event_key": payload.event_key, + "request_fingerprint": payload.request_fingerprint, + "request_json": payload.request_json, + } + ) + if creates: + await session.execute(insert(active), list(creates.values())) + if changes: + await session.execute( + update( + SeriesExternalIdentity + if kind is MetadataEntityKind.SERIES + else IssueExternalIdentity + ), + list(changes.values()), + ) + if legacy_changes: + await session.execute( + update(Series if kind is MetadataEntityKind.SERIES else Issue), + list(legacy_changes.values()), + ) + rows = await session.execute( + insert(history).returning(history.c.id, history.c.event_key), + events, + execution_options={"insertmanyvalues_page_size": 50}, + ) + for event_id, event_key in rows: + receipts[event_key] = IdentityAttachmentReceipt(event_id, False) + return receipts + + +async def _validate_legacy_owners( + session: AsyncSession, + kind: MetadataEntityKind, + requests: list[IdentityEventRequest], + targets: dict[int, RowMapping], +) -> None: + model = Series if kind is MetadataEntityKind.SERIES else Issue + legacy_claims = [ + (request.local_id, int(request.evidence.claim.identity.external_id)) + for request in requests + if request.evidence.claim.identity.namespace is IdentityNamespace.COMICVINE + ] + if not legacy_claims: + return + owners = { + external_id: local_id + for local_id, external_id in ( + await session.execute( + select(model.id, model.comicvine_id).where( + model.comicvine_id.in_([value for _, value in legacy_claims]) + ) + ) + ).all() + } + for local_id, external_id in legacy_claims: + if ( + targets[local_id].comicvine_id not in (None, external_id) + or owners.get(external_id, local_id) != local_id + ): + raise IdentityAttachmentConflictError( + "ComicVine compatibility identity disagrees with attachment" + ) + + +async def _validate_issue_parents( + session: AsyncSession, requests: list[IdentityEventRequest], targets: dict[int, RowMapping] +) -> None: + table = Base.metadata.tables["series_external_identities"] + rows = ( + ( + await session.execute( + select(table, Series.comicvine_id) + .join(Series, Series.id == table.c.series_id) + .where(table.c.series_id.in_({row.series_id for row in targets.values()})) + ) + ) + .mappings() + .all() + ) + parents = {(row.series_id, row.identity_namespace): row for row in rows} + for request in requests: + proof = request.evidence.parent_identity + assert proof is not None + parent = parents.get((targets[request.local_id].series_id, proof.namespace)) + if ( + parent is None + or parent.external_id != proof.external_id + or parent.verification_state is not IdentityVerificationState.VERIFIED + ): + raise IdentityAttachmentConflictError( + "Issue evidence disagrees with its verified local series" + ) + if proof.namespace is IdentityNamespace.COMICVINE and parent.comicvine_id != int( + proof.external_id + ): + raise IdentityAttachmentConflictError( + "Issue parent has inconsistent ComicVine ownership" + ) diff --git a/src/pullbox/services/story_arc_catalog_persistence.py b/src/pullbox/services/story_arc_catalog_persistence.py index b7bcd268..3d0f6847 100644 --- a/src/pullbox/services/story_arc_catalog_persistence.py +++ b/src/pullbox/services/story_arc_catalog_persistence.py @@ -5,6 +5,7 @@ from datetime import date from pathlib import Path from typing import TYPE_CHECKING +from uuid import UUID, uuid5 from sqlalchemy import func, select @@ -12,6 +13,19 @@ from pullbox.core.issue_numbers import parse_issue_number_text from pullbox.core.library_naming import build_series_relative_path from pullbox.core.library_policy import load_effective_library_ingest_policy +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, +) from pullbox.core.naming import classify_series_type, detect_issue_type_from_metadata_title from pullbox.core.type_semantics import canonical_issue_type_for_series_type from pullbox.models.issue import Issue, IssueStatus, IssueType @@ -19,6 +33,10 @@ from pullbox.models.publisher import Publisher from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType from pullbox.services.library_root_management import validate_managed_library_root +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + attach_verified_identities, +) from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, exact_provider_id if TYPE_CHECKING: @@ -135,9 +153,57 @@ async def seed_members( session.add(issue) await session.flush() result[provider_id] = issue + await _attach_member_identities(session, preview, parents, result) return result +async def _attach_member_identities( + session: AsyncSession, + preview: StoryArcCatalogPreview, + parents: dict[str, Series], + issues: dict[str, Issue], +) -> None: + """Persist verified preview evidence without another provider or file read.""" + operation_id = uuid5(UUID("e5975ab0-13f6-4e1f-b7af-c3a00ccab88b"), preview.fingerprint) + parent_ids = {row.id: key for key, row in parents.items()} + requests = [] + for kind, rows in ((MetadataEntityKind.SERIES, parents), (MetadataEntityKind.ISSUE, issues)): + for provider_id, row in rows.items(): + identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, provider_id) + parent = ( + ExternalIdentityRef( + IdentityNamespace.COMICVINE, + MetadataEntityKind.SERIES, + parent_ids[row.series_id], + ) + if isinstance(row, Issue) + else None + ) + requests.append( + IdentityEventRequest( + operation_id, + row.id, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, + IdentityEvidenceKind.PROVIDER_RESULT, + MetadataSource.COMICVINE_API, + ), + preview.fingerprint, + source_identity=identity, + parent_identity=parent, + ), + ) + ) + try: + await attach_verified_identities(session, requests) + except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc: + raise StoryArcCatalogError( + "identity_conflict", "Canonical identity needs review before adding these members" + ) from exc + + async def _new_series(session: AsyncSession, metadata: SeriesMetadata, root: LibraryRoot) -> Series: series = Series( comicvine_id=exact_provider_id(metadata.provider_id), diff --git a/tests/integration/metadata_identity/test_attachment_service.py b/tests/integration/metadata_identity/test_attachment_service.py new file mode 100644 index 00000000..0f892ae9 --- /dev/null +++ b/tests/integration/metadata_identity/test_attachment_service.py @@ -0,0 +1,466 @@ +"""Runtime attachment, replay, ownership and compatibility on both databases.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import delete, event, func, select, update + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventEvidence, + IdentityEventReplayConflictError, + IdentityEventRequest, + prepare_identity_event, +) +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, + IdentityVerificationState, +) +from pullbox.models import Issue, Series +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, +) +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + attach_verified_identities, +) + + +def _request(target, external="42", *, kind=MetadataEntityKind.SERIES, parent=None): + identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, external) + return IdentityEventRequest( + UUID(int=42), + target, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.COMICVINE_API + ), + "a" * 64, + source_identity=identity, + parent_identity=parent, + ), + ) + + +async def _series(factory, count=2, legacy=None): + async with factory.begin() as session: + rows = [Series(title=f"Series {i}", sort_title=f"series {i}") for i in range(count)] + if legacy is not None: + rows[0].comicvine_id = legacy + session.add_all(rows) + await session.flush() + return [row.id for row in rows] + + +async def _counts(factory): + async with factory() as session: + return tuple( + [ + await session.scalar(select(func.count()).select_from(model)) + for model in (SeriesExternalIdentity, SeriesIdentityEvent) + ] + ) + + +async def test_attachment_dual_writes_and_replay_does_not_mutate(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + request = _request(first) + async with factory.begin() as session: + receipts = await attach_verified_identities(session, [request]) + assert len(receipts) == 1 and not receipts[0].replayed + async with factory.begin() as session: + replay = await attach_verified_identities(session, [request]) + assert replay[0].replayed and replay[0].event_id == receipts[0].event_id + owner = await session.scalar(select(SeriesExternalIdentity)) + assert owner.series_id == first and owner.external_id == "42" and owner.revision == 1 + assert owner.verified_at.tzinfo is not None and owner.last_seen_at == owner.verified_at + assert (await session.get(Series, first)).comicvine_id == 42 + history = await session.scalar(select(SeriesIdentityEvent)) + assert history.request_json == prepare_identity_event(request).request_json + assert await _counts(factory) == (1, 1) + + +async def test_attachment_never_commits_even_when_sqlite_starts_with_savepoint(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory() as session: + await attach_verified_identities(session, [_request(first)]) + await session.rollback() + assert await _counts(factory) == (0, 0) + async with factory() as session: + assert (await session.get(Series, first)).comicvine_id is None + + +@pytest.mark.parametrize( + "collision", ["legacy_owner", "legacy_target", "active_owner", "active_target"] +) +async def test_attachment_conflicts_roll_back_entire_batch_even_if_caller_catches( + identity_probe_db, collision +): + _, factory, _ = identity_probe_db + first, second, other = await _series(factory, 3) + async with factory.begin() as session: + if collision.startswith("legacy"): + target = other if collision == "legacy_owner" else second + value = 43 if collision == "legacy_owner" else 99 + await session.execute( + update(Series).where(Series.id == target).values(comicvine_id=value) + ) + else: + session.add( + SeriesExternalIdentity( + series_id=other if collision == "active_owner" else second, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="43" if collision == "active_owner" else "99", + verification_state=IdentityVerificationState.STALE, + evidence_kind=IdentityEvidenceKind.LEGACY_BACKFILL, + ) + ) + async with factory.begin() as session: + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities(session, [_request(first), _request(second, "43")]) + assert (await _counts(factory))[1] == 0 + async with factory() as session: + assert (await session.get(Series, first)).comicvine_id is None + + +@pytest.mark.parametrize("state", ["rejected", "conflicted"]) +async def test_attachment_preserves_retained_decisions_without_active_owner( + identity_probe_db, state +): + _, factory, _ = identity_probe_db + first, second = await _series(factory) + request = _request(first) + prior = prepare_identity_event(replace(request, operation_id=uuid4())) + async with factory.begin() as session: + session.add( + SeriesIdentityEvent( + series_id=first, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="42", + verification_state=IdentityVerificationState(state), + evidence_kind=IdentityEvidenceKind.COMICINFO_XML, + event_key=prior.event_key, + request_fingerprint=prior.request_fingerprint, + request_json=prior.request_json, + ) + ) + async with factory.begin() as session: + with pytest.raises(IdentityReviewRequiredError): + await attach_verified_identities(session, [request]) + # Rejected candidates never reserve ownership on other local entities. + await attach_verified_identities(session, [_request(second)]) + assert await _counts(factory) == (1, 2) + + +async def test_replay_does_not_restore_released_ownership(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first)]) + async with factory.begin() as session: + await session.execute(delete(SeriesExternalIdentity)) + await session.execute(update(Series).values(comicvine_id=None)) + async with factory.begin() as session: + receipt = await attach_verified_identities(session, [_request(first)]) + assert len(receipt) == 1 and receipt[0].replayed + assert await _counts(factory) == (0, 1) + + +async def test_replay_rejects_changed_parent_or_corrupt_payload(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first)]) + async with factory.begin() as session: + await session.execute(update(SeriesIdentityEvent).values(request_json='{"changed":true}')) + async with factory.begin() as session: + with pytest.raises(IdentityEventReplayConflictError): + await attach_verified_identities(session, [_request(first)]) + + +@pytest.mark.parametrize("mode", ["same_retry", "different_observation", "competing_owner"]) +async def test_concurrent_attachments_serialize_without_duplicate_events(identity_probe_db, mode): + _, factory, _ = identity_probe_db + first, second = await _series(factory) + requests = [_request(first), _request(second if mode == "competing_owner" else first)] + if mode == "different_observation": + requests[1] = replace(requests[1], operation_id=uuid4()) + + async def attach(request): + try: + async with factory.begin() as session: + return (await attach_verified_identities(session, [request]))[0] + except IdentityAttachmentConflictError: + return "conflict" + + receipts = await asyncio.gather(*(attach(request) for request in requests)) + assert receipts.count("conflict") == (1 if mode == "competing_owner" else 0) + assert await _counts(factory) == (1, 2 if mode == "different_observation" else 1) + + +@pytest.mark.parametrize( + "problem", [None, "wrong_parent", "missing_parent", "stale_parent", "legacy_parent_drift"] +) +async def test_issue_attachment_requires_verified_parent_agreement(identity_probe_db, problem): + _, factory, _ = identity_probe_db + first, second = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first), _request(second, "43")]) + issue = Issue(series_id=first, issue_number=1) + session.add(issue) + await session.flush() + issue_id = issue.id + if problem == "stale_parent": + await session.execute(update(SeriesExternalIdentity).values(verification_state="stale")) + if problem == "legacy_parent_drift": + await session.execute(update(Series).where(Series.id == first).values(comicvine_id=99)) + parent = ExternalIdentityRef( + IdentityNamespace.COMICVINE, + MetadataEntityKind.SERIES, + "43" if problem == "wrong_parent" else "42", + ) + request = _request( + issue_id, + "91", + kind=MetadataEntityKind.ISSUE, + parent=None if problem == "missing_parent" else parent, + ) + async with factory.begin() as session: + if problem: + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities(session, [request]) + else: + receipts = await attach_verified_identities(session, [request]) + assert len(receipts) == 1 + assert (await session.get(Issue, issue_id)).comicvine_id == 91 + assert (await session.scalar(select(IssueExternalIdentity))).external_id == "91" + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == ( + 0 if problem else 1 + ) + + +async def test_attachment_batches_queries_and_rolls_back_across_batch_boundary(identity_probe_db): + engine, factory, _ = identity_probe_db + ids = await _series(factory, 450) + statements = [] + + def track(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + receipts = await attach_verified_identities( + session, [_request(i, str(i + 100)) for i in ids] + ) + assert len(receipts) == 450 + assert len(statements) < 60, "Identity attachment must not query per issue" + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) + assert await _counts(factory) == (450, 450) + + +async def test_rejects_unsupported_actions_and_missing_targets(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities(session, [_request(999)]) + request = replace( + _request(999), + action=IdentityVerificationAction.CONFIRM, + actor=IdentityEventActor.USER, + actor_user_id=1, + review_revision=1, + ) + with pytest.raises(ValueError): + await attach_verified_identities(session, [request]) + + +@pytest.mark.parametrize("preexisting", [False, True]) +async def test_multiple_observations_of_same_claim_in_batch_share_one_owner( + identity_probe_db, preexisting +): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + original = _request(first) + if preexisting: + async with factory.begin() as session: + await attach_verified_identities(session, [original]) + requests = [replace(original, operation_id=uuid4()) for _ in range(2)] + async with factory.begin() as session: + receipts = await attach_verified_identities(session, [*requests, requests[0]]) + assert len(receipts) == 3 and receipts[0].event_id == receipts[-1].event_id + assert await _counts(factory) == (1, 3 if preexisting else 2) + async with factory() as session: + assert (await session.scalar(select(SeriesExternalIdentity))).revision == ( + 3 if preexisting else 2 + ) + + +async def test_batch_limit_keeps_sqlite_bind_counts_below_999(identity_probe_db): + engine, factory, _ = identity_probe_db + if engine.dialect.name != "sqlite": + return + ids = await _series(factory, 210) + bind_counts = [] + + def track(_conn, _cursor, _statement, parameters, _context, executemany): + # executemany binds one parameter row at a time, unlike insertmanyvalues. + bind_counts.append( + len(parameters[0]) + if executemany and isinstance(parameters[0], tuple) + else len(parameters) + ) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + await attach_verified_identities(session, [_request(i, str(i + 100)) for i in ids]) + assert max(bind_counts) <= 999 + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) + + +async def test_late_batch_conflict_rolls_back_earlier_batches(identity_probe_db): + _, factory, _ = identity_probe_db + ids = await _series(factory, 210) + requests = [_request(i, str(i + 100)) for i in ids] + async with factory.begin() as session: + await session.execute(update(Series).where(Series.id == ids[-1]).values(comicvine_id=9999)) + async with factory.begin() as session: + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities(session, requests) + assert await _counts(factory) == (0, 0) + async with factory() as session: + assert (await session.get(Series, ids[0])).comicvine_id is None + + +@pytest.mark.parametrize("state", ["stale", "conflicted"]) +async def test_active_state_cannot_be_cleared_without_review(identity_probe_db, state): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first)]) + async with factory.begin() as session: + await session.execute(update(SeriesExternalIdentity).values(verification_state=state)) + request = replace(_request(first), operation_id=uuid4()) + async with factory.begin() as session: + if state == "conflicted": + with pytest.raises(IdentityReviewRequiredError): + await attach_verified_identities(session, [request]) + else: + await attach_verified_identities(session, [request]) + owner = await session.scalar(select(SeriesExternalIdentity)) + assert owner.verification_state == ("conflicted" if state == "conflicted" else "verified") + + +async def test_other_namespace_does_not_overwrite_comicvine_compatibility(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory, legacy=42) + request = _request(first) + identity = ExternalIdentityRef(IdentityNamespace.METRON, MetadataEntityKind.SERIES, "77") + metron = replace( + request, + evidence=IdentityEventEvidence( + ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.METRON_API + ), + "b" * 64, + source_identity=identity, + ), + ) + async with factory.begin() as session: + await attach_verified_identities(session, [request, metron]) + assert (await session.get(Series, first)).comicvine_id == 42 + assert await _counts(factory) == (2, 2) + + +async def test_issue_replay_rejects_different_parent_proof(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first)]) + issue = Issue(series_id=first, issue_number=1) + session.add(issue) + await session.flush() + target = issue.id + parent = ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42") + request = _request(target, "91", kind=MetadataEntityKind.ISSUE, parent=parent) + async with factory.begin() as session: + await attach_verified_identities(session, [request]) + changed = replace( + request, + evidence=replace(request.evidence, parent_identity=replace(parent, external_id="43")), + ) + async with factory.begin() as session: + with pytest.raises(IdentityEventReplayConflictError): + await attach_verified_identities(session, [changed]) + + +async def test_loaded_owner_stays_consistent_with_batched_update(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first)]) + async with factory.begin() as session: + owner = await session.scalar(select(SeriesExternalIdentity)) + assert owner.revision == 1 + await attach_verified_identities(session, [replace(_request(first), operation_id=uuid4())]) + assert owner.revision == 2 + + +async def test_mixed_entity_batches_lock_all_series_before_issues(identity_probe_db, monkeypatch): + from pullbox.services import metadata_identity_attachment as attachment + + _, factory, _ = identity_probe_db + first, second = await _series(factory) + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first), _request(second, "43")]) + issues = [Issue(series_id=parent, issue_number=1) for parent in (first, second)] + session.add_all(issues) + await session.flush() + issue_ids = [issue.id for issue in issues] + original = attachment._locked_targets + + async def interleave(session, kind, ids): + rows = await original(session, kind, ids) + await asyncio.sleep(0.02) + return rows + + monkeypatch.setattr(attachment, "_locked_targets", interleave) + + async def attach(index): + own, other = (first, second)[index], (first, second)[1 - index] + parent = ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42" if other == first else "43" + ) + requests = [ + replace(_request(own, "42" if own == first else "43"), operation_id=uuid4()), + _request( + issue_ids[1 - index], str(91 + index), kind=MetadataEntityKind.ISSUE, parent=parent + ), + ] + async with factory.begin() as session: + return await attach_verified_identities(session, requests) + + receipts = await asyncio.wait_for(asyncio.gather(attach(0), attach(1)), timeout=10) + assert [len(batch) for batch in receipts] == [2, 2] diff --git a/tests/unit/test_metadata_identity_parent_evidence.py b/tests/unit/test_metadata_identity_parent_evidence.py new file mode 100644 index 00000000..3b8aee6b --- /dev/null +++ b/tests/unit/test_metadata_identity_parent_evidence.py @@ -0,0 +1,47 @@ +"""Parent agreement is part of the retained request, not a transient assertion.""" + +from dataclasses import replace + +import pytest + +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_events import prepare_identity_event +from tests.fixtures.metadata_identity_events import identity_event_request + + +def test_issue_parent_changes_semantics_but_not_retry_slot(): + request = identity_event_request(MetadataEntityKind.ISSUE, 1) + + def with_parent(key): + return replace( + request, + evidence=replace( + request.evidence, + parent_identity=ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, key + ), + ), + ) + + first, second = [prepare_identity_event(with_parent(key)) for key in ("21", "22")] + assert first.event_key == second.event_key + assert first.request_fingerprint != second.request_fingerprint + assert '"parent_identity"' in first.request_json + + +@pytest.mark.parametrize( + "target,namespace,parent_kind", + [ + (MetadataEntityKind.SERIES, IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES), + (MetadataEntityKind.ISSUE, IdentityNamespace.METRON, MetadataEntityKind.SERIES), + (MetadataEntityKind.ISSUE, IdentityNamespace.COMICVINE, MetadataEntityKind.ISSUE), + ], +) +def test_rejects_wrong_parent_kind_or_namespace(target, namespace, parent_kind): + request = identity_event_request(target, 1) + with pytest.raises(ValueError): + replace(request.evidence, parent_identity=ExternalIdentityRef(namespace, parent_kind, "21")) diff --git a/tests/unit/test_story_arc_catalog.py b/tests/unit/test_story_arc_catalog.py index bcf385a5..adfe811b 100644 --- a/tests/unit/test_story_arc_catalog.py +++ b/tests/unit/test_story_arc_catalog.py @@ -80,6 +80,73 @@ async def _root(session, tmp_path): return root +async def test_arc_member_seeding_dual_writes_verified_identities(db_session, tmp_path): + from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, + ) + from pullbox.services.story_arc_catalog import StoryArcCatalogService + + root = await _root(db_session, tmp_path) + provider = _provider() + service = StoryArcCatalogService(provider) + preview = await service.preview("31") + await service.add( + db_session, preview, ordered_issue_provider_ids=["11", "12"], library_root_id=root.id + ) + for model in ( + SeriesExternalIdentity, + IssueExternalIdentity, + SeriesIdentityEvent, + IssueIdentityEvent, + ): + rows = (await db_session.scalars(select(model))).all() + assert len(rows) == 2, f"{model.__name__} must participate in atomic arc adoption" + assert all(row.verification_state == "verified" for row in rows) + assert all(row.evidence_kind == "provider_result" for row in rows) + provider.get_story_arc_issues.assert_awaited_once() + assert provider.get_series.await_count == 2 + assert not list(tmp_path.iterdir()) + + +async def test_arc_generic_identity_conflict_rolls_back_member_creation(db_session, tmp_path): + from pullbox.core.metadata_identity import IdentityEvidenceKind, IdentityNamespace + from pullbox.core.metadata_identity_state import IdentityVerificationState + from pullbox.models.metadata_identity import ( + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, + ) + from pullbox.services.story_arc_catalog import StoryArcCatalogError, StoryArcCatalogService + + root = await _root(db_session, tmp_path) + reserved = Series(title="Reserved", sort_title="Reserved") + db_session.add(reserved) + await db_session.flush() + db_session.add( + SeriesExternalIdentity( + series_id=reserved.id, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="22", + verification_state=IdentityVerificationState.CONFLICTED, + evidence_kind=IdentityEvidenceKind.COMICINFO_XML, + ) + ) + await db_session.flush() + service = StoryArcCatalogService(_provider()) + preview = await service.preview("31") + with pytest.raises(StoryArcCatalogError, match="identity needs review"): + await service.add( + db_session, preview, ordered_issue_provider_ids=["11", "12"], library_root_id=root.id + ) + assert await db_session.scalar(select(func.count()).select_from(Series)) == 1 + assert await db_session.scalar(select(func.count()).select_from(SeriesExternalIdentity)) == 1 + for model in (StoryArc, Issue, SeriesIdentityEvent, IssueIdentityEvent): + assert await db_session.scalar(select(func.count()).select_from(model)) == 0 + + def _root_policy(root_id, template): from pullbox.models.library import LibraryRootPolicy, LibraryRootPolicySource From 462994fb1a9e021b86adac250dcd4617bd43700b Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 21:23:48 -0700 Subject: [PATCH 11/97] feat: attach identities during metadata hydration and imports --- src/pullbox/services/metadata_service.py | 77 ++- .../services/metadata_writer_identity.py | 234 +++++++++ src/pullbox/services/series_service.py | 16 +- .../test_metadata_writer_integration.py | 450 ++++++++++++++++++ tests/unit/test_refresh_series_metadata.py | 3 +- tests/unit/test_series_targeted_import.py | 6 + 6 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 src/pullbox/services/metadata_writer_identity.py create mode 100644 tests/integration/metadata_identity/test_metadata_writer_integration.py diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py index 3ce9db72..b06cab90 100644 --- a/src/pullbox/services/metadata_service.py +++ b/src/pullbox/services/metadata_service.py @@ -15,8 +15,9 @@ import structlog from sqlalchemy import delete, select -from pullbox.core.exceptions import NotFoundError, ProviderError +from pullbox.core.exceptions import NotFoundError, ProviderError, ValidationError from pullbox.core.issue_numbers import format_issue_number, normalize_issue_number_text +from pullbox.core.metadata_identity import MetadataEntityKind from pullbox.core.name_matcher import NameMatcher from pullbox.core.naming import ( classify_series_type, @@ -38,6 +39,12 @@ from pullbox.providers.metadata.comicvine import ComicVineError from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata from pullbox.services.cover_cache_service import purge_series_cover_cache +from pullbox.services.metadata_writer_identity import ( + attach_issue_summary_identities, + attach_series_metadata_identity, + comicvine_identity, + metadata_write_scope, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -45,6 +52,7 @@ from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesSearchResult from pullbox.providers.metadata.comicvine import ComicVineProvider from pullbox.services.catalog.reader import CatalogReader + from pullbox.services.metadata_writer_identity import ImportIdentityOrigin logger = structlog.get_logger(__name__) @@ -181,12 +189,27 @@ async def upsert_series_metadata( session: AsyncSession, comicvine_id: int, meta: SeriesMetadata, + *, + identity_origin: ImportIdentityOrigin | None = None, ) -> Series: """Create or update a local Series row from prefetched provider metadata. This split lets long-running workflows fetch provider data before they enter a write-heavy section, keeping SQLite write locks short. """ + identity = comicvine_identity(MetadataEntityKind.SERIES, meta.provider_id) + if identity.external_id != str(comicvine_id): + raise ValidationError("Series metadata does not match the requested ComicVine ID.") + async with metadata_write_scope(session): + return await self._upsert_series_metadata(session, comicvine_id, meta, identity_origin) + + async def _upsert_series_metadata( + self, + session: AsyncSession, + comicvine_id: int, + meta: SeriesMetadata, + identity_origin: ImportIdentityOrigin | None, + ) -> Series: log = logger.bind(comicvine_id=comicvine_id) source = "pullbox_catalog" if isinstance(meta, CatalogSeriesMetadata) else "comicvine" @@ -199,7 +222,9 @@ async def upsert_series_metadata( publisher_id = await self._ensure_publisher(session, meta.publisher) existing = ( - await session.execute(select(Series).where(Series.comicvine_id == comicvine_id)) + await session.execute( + select(Series).where(Series.comicvine_id == comicvine_id).with_for_update() + ) ).scalar_one_or_none() if ( @@ -208,6 +233,7 @@ async def upsert_series_metadata( and existing.metadata_source == "comicvine" ): # Basic catalog hydration must never replace a completed full refresh. + await attach_series_metadata_identity(session, existing, meta, identity_origin) return existing if existing: existing.title = meta.title @@ -250,6 +276,7 @@ async def upsert_series_metadata( ) session.add(series) await session.flush() + await attach_series_metadata_identity(session, series, meta, identity_origin) await purge_series_cover_cache( session, series.id, @@ -258,6 +285,8 @@ async def upsert_series_metadata( series.cover_path = None log.debug("metadata_series_created", series_id=series.id) + if existing: + await attach_series_metadata_identity(session, series, meta, identity_origin) await self.classify_and_link_series(session, series) return series @@ -755,21 +784,50 @@ async def upsert_issue_summaries( summaries: list[IssueSummary], *, infer_series_type_from_summaries: bool = False, + identity_origin: ImportIdentityOrigin | None = None, ) -> list[Issue]: """Create or update local Issue rows from prefetched provider summaries.""" + for summary in summaries: + comicvine_identity(MetadataEntityKind.ISSUE, summary.provider_id) + async with metadata_write_scope(session): + return await self._upsert_issue_summaries( + session, + series, + summaries, + infer_series_type_from_summaries=infer_series_type_from_summaries, + identity_origin=identity_origin, + ) + + async def _upsert_issue_summaries( + self, + session: AsyncSession, + series: Series, + summaries: list[IssueSummary], + *, + infer_series_type_from_summaries: bool, + identity_origin: ImportIdentityOrigin | None, + ) -> list[Issue]: series_id = series.id if series_id is None: raise NotFoundError("Series", "unpersisted") + # Match the identity writer's parent-before-child lock order before + # ORM autoflush can update any existing issue rows. + await session.execute(select(Series.id).where(Series.id == series_id).with_for_update()) log = logger.bind(series_id=series_id) created = [] + identity_members: list[tuple[Issue, IssueSummary]] = [] provider_issue_ids = [int(summary.provider_id) for summary in summaries] existing_result = await session.execute(select(Issue).where(Issue.series_id == series_id)) existing_issues = list(existing_result.scalars().all()) - provider_result = await session.execute( - select(Issue).where(Issue.comicvine_id.in_(provider_issue_ids)) - ) - existing_provider_issues = list(provider_result.scalars().all()) + existing_provider_issues: list[Issue] = [] + for offset in range(0, len(provider_issue_ids), 200): + provider_result = await session.execute( + select(Issue).where( + Issue.comicvine_id.in_(provider_issue_ids[offset : offset + 200]) + ) + ) + existing_provider_issues.extend(provider_result.scalars().all()) existing_by_text = {issue.effective_issue_number_text: issue for issue in existing_issues} legacy_by_number = { issue.issue_number: issue @@ -813,6 +871,7 @@ async def upsert_issue_summaries( # A basic snapshot cannot establish that live fields are stale. # Keep its identity and fields, and do not infer parent type here. summary_evidence_types.append(IssueType.ISSUE) + identity_members.append((existing_by_provider, summary)) continue if existing_by_provider is not None and existing_by_provider is not existing: if existing is None: @@ -865,7 +924,7 @@ async def upsert_issue_summaries( existing_by_text.pop(old_issue_number_text, None) existing_by_text[exact_issue_number_text] = existing if assign_provider_issue_id: - existing.comicvine_id = provider_issue_id + identity_members.append((existing, summary)) if summary.title: existing.title = summary.title if summary.release_date: @@ -884,7 +943,7 @@ async def upsert_issue_summaries( else: issue = Issue( series_id=series_id, - comicvine_id=provider_issue_id if assign_provider_issue_id else None, + comicvine_id=None, issue_number=summary.issue_number, issue_number_text=exact_issue_number_text, title=summary.title, @@ -898,8 +957,10 @@ async def upsert_issue_summaries( existing_by_text[exact_issue_number_text] = issue if assign_provider_issue_id: existing_by_provider_id[provider_issue_id] = issue + identity_members.append((issue, summary)) await session.flush() + await attach_issue_summary_identities(session, series, identity_members, identity_origin) # Only a complete catalog where every summary has explicit non-standard # evidence may infer the series type. Recent/targeted subsets and a mix diff --git a/src/pullbox/services/metadata_writer_identity.py b/src/pullbox/services/metadata_writer_identity.py new file mode 100644 index 00000000..fdd732bb --- /dev/null +++ b/src/pullbox/services/metadata_writer_identity.py @@ -0,0 +1,234 @@ +"""Evidence adapters for prefetched ComicVine metadata and local import records.""" + +from __future__ import annotations + +import hashlib +import json +from contextlib import asynccontextmanager +from dataclasses import asdict, dataclass +from datetime import date +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import false, select, update + +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventEvidence, + IdentityEventRequest, + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, +) +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, + IdentityVerificationState, +) +from pullbox.models import Series +from pullbox.models.import_job import ImportedSeries, ImportJob, ImportSourceType +from pullbox.models.metadata_identity import SeriesExternalIdentity +from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + attach_verified_identities, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + + from pullbox.models import Issue + from pullbox.providers.base import IssueSummary, SeriesMetadata + + +@dataclass(frozen=True) +class ImportIdentityOrigin: + record_id: int | None + + +@dataclass(frozen=True) +class _LocalEvidence: + kind: IdentityEvidenceKind + locator: IdentityEvidenceLocator + snapshot: dict[str, object] + + +def comicvine_identity(kind: MetadataEntityKind, external_id: str) -> ExternalIdentityRef: + try: + identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, external_id) + if int(identity.external_id) >= 2**63: + raise ValueError("Out of range") + return identity + except ValueError as exc: + raise ValidationError("Metadata contains an invalid ComicVine identity.") from exc + + +@asynccontextmanager +async def metadata_write_scope(session: AsyncSession) -> AsyncIterator[None]: + """Roll back metadata as well as ownership if the caller catches a conflict.""" + if session.get_bind().dialect.name == "sqlite": + await session.execute( + update(Series).where(false()).values(comicvine_id=Series.comicvine_id) + ) + try: + async with session.begin_nested(): + yield + except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc: + raise ValidationError( + "Metadata identity conflicts with an existing match. Review the match before retrying.", + details={"reason": "metadata_identity_conflict"}, + ) from exc + + +async def _import_evidence( + session: AsyncSession, origin: ImportIdentityOrigin | None, parent: ExternalIdentityRef +) -> _LocalEvidence | None: + if origin is None: + return None + if type(origin.record_id) is not int or origin.record_id <= 0: + raise ValidationError("Import identity evidence requires a saved review record.") + row = ( + await session.execute( + select( + ImportedSeries.id, + ImportedSeries.import_job_id, + ImportedSeries.cv_id, + ImportedSeries.user_selected_cv_id, + ImportedSeries.cv_match_method, + ImportJob.source_type, + ) + .join(ImportJob, ImportJob.id == ImportedSeries.import_job_id) + .where(ImportedSeries.id == origin.record_id) + ) + ).one_or_none() + if row is None or (row.user_selected_cv_id or row.cv_id) != int(parent.external_id): + raise ValidationError("The saved import match changed. Reload the review before retrying.") + trusted_mylar = ( + row.source_type == ImportSourceType.MYLAR3 + and row.cv_match_method == "mylar3_cv_id" + and row.user_selected_cv_id is None + ) + return _LocalEvidence( + IdentityEvidenceKind.MYLAR_DATABASE if trusted_mylar else IdentityEvidenceKind.MIGRATION, + IdentityEvidenceLocator(IdentityEvidenceRecordKind.IMPORTED_SERIES, row.id), + dict(row._mapping), + ) + + +def _json_value(value: object) -> str: + if isinstance(value, date): + return value.isoformat() + raise TypeError("Unsupported metadata snapshot value") + + +def _request( + operation: UUID, + local_id: int, + identity: ExternalIdentityRef, + metadata: SeriesMetadata | IssueSummary | None, + local: _LocalEvidence | None, + parent: ExternalIdentityRef | None = None, +) -> IdentityEventRequest: + snapshot = { + "metadata": asdict(metadata) if metadata is not None else None, + "local": local.snapshot if local is not None else None, + } + revision = hashlib.sha256( + json.dumps( + snapshot, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + default=_json_value, + ).encode() + ).hexdigest() + source = ( + None + if local + else ( + MetadataSource.COMICVINE_LOCAL + if isinstance(metadata, CatalogSeriesMetadata | CatalogIssueSummary) + else MetadataSource.COMICVINE_API + ) + ) + return IdentityEventRequest( + operation, + local_id, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, local.kind if local else IdentityEvidenceKind.PROVIDER_RESULT, source + ), + revision, + locator=local.locator if local else None, + source_identity=identity if source else None, + parent_identity=parent, + ), + ) + + +async def attach_series_metadata_identity( + session: AsyncSession, + series: Series, + metadata: SeriesMetadata, + origin: ImportIdentityOrigin | None, +) -> None: + identity = comicvine_identity(MetadataEntityKind.SERIES, metadata.provider_id) + local = await _import_evidence(session, origin, identity) + await attach_verified_identities( + session, [_request(uuid4(), series.id, identity, metadata, local)] + ) + + +async def attach_issue_summary_identities( + session: AsyncSession, + series: Series, + members: list[tuple[Issue, IssueSummary]], + origin: ImportIdentityOrigin | None, +) -> None: + if not members: + return + parent = comicvine_identity(MetadataEntityKind.SERIES, str(series.comicvine_id)) + local = await _import_evidence(session, origin, parent) + operation = uuid4() + requests = [] + existing = await session.scalar( + select(SeriesExternalIdentity).where( + SeriesExternalIdentity.series_id == series.id, + SeriesExternalIdentity.identity_namespace == IdentityNamespace.COMICVINE, + ) + ) + if existing is None: + # Existing legacy rows need an honest local backfill, not invented API evidence. + parent_local = local or _LocalEvidence( + IdentityEvidenceKind.LEGACY_BACKFILL, + IdentityEvidenceLocator(IdentityEvidenceRecordKind.SERIES, series.id), + {"id": series.id, "comicvine_id": series.comicvine_id}, + ) + requests.append(_request(operation, series.id, parent, None, parent_local)) + elif ( + existing.external_id != parent.external_id + or existing.verification_state != IdentityVerificationState.VERIFIED + ): + raise IdentityAttachmentConflictError("Issue catalog requires a verified series identity") + requests.extend( + _request( + operation, + issue.id, + comicvine_identity(MetadataEntityKind.ISSUE, summary.provider_id), + summary, + local, + parent, + ) + for issue, summary in members + ) + await attach_verified_identities(session, requests) diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py index 2b279538..7e81cb80 100644 --- a/src/pullbox/services/series_service.py +++ b/src/pullbox/services/series_service.py @@ -42,6 +42,7 @@ purge_series_cover_cache, ) from pullbox.services.library_root_management import validate_managed_library_root +from pullbox.services.metadata_writer_identity import ImportIdentityOrigin from pullbox.services.series_delete_targets import ( SeriesDeleteContext as SeriesDeleteContext, ) @@ -269,7 +270,13 @@ async def add_from_import_review_targeted( cached_meta = await cached_lookup(self._metadata, cv_id) if cached_meta is not None: series_meta = cached_meta - series = await self._metadata.upsert_series_metadata(session, cv_id, series_meta) + identity_origin = ImportIdentityOrigin(import_series.id) + series = await self._metadata.upsert_series_metadata( + session, + cv_id, + series_meta, + identity_origin=identity_origin, + ) series.monitored = search_on_add series.issue_catalog_state = IssueCatalogState.HYDRATING series.issue_catalog_error = None @@ -322,7 +329,12 @@ async def add_from_import_review_targeted( import_series.diagnostics = diagnostics if issue_summaries: - await self._metadata.upsert_issue_summaries(session, series, issue_summaries) + await self._metadata.upsert_issue_summaries( + session, + series, + issue_summaries, + identity_origin=identity_origin, + ) await session.flush() return series diff --git a/tests/integration/metadata_identity/test_metadata_writer_integration.py b/tests/integration/metadata_identity/test_metadata_writer_integration.py new file mode 100644 index 00000000..6c609c3e --- /dev/null +++ b/tests/integration/metadata_identity/test_metadata_writer_integration.py @@ -0,0 +1,450 @@ +"""Real metadata writers maintain generic ownership without extra provider work.""" + +from dataclasses import asdict, replace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import event, func, select + +from pullbox.core.events import EventBus +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import IdentityEvidenceKind, IdentityNamespace +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Issue, Series +from pullbox.models.import_job import ImportedSeries, ImportJob, ImportSourceType +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, +) +from pullbox.providers.base import IssueSummary, SeriesMetadata +from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata +from pullbox.services.metadata_service import MetadataService +from pullbox.services.metadata_writer_identity import ImportIdentityOrigin +from pullbox.services.series_service import SeriesService + + +def _profile(key="42"): + return SeriesMetadata(key, "Identity Series", None, 2020, None, None, None, None, None, 3, None) + + +def _summary(key="91", number=1): + return IssueSummary(key, float(number), "Issue", None, None, "issue", str(number)) + + +def _service(tmp_path): + provider = MagicMock() + provider.get_series = AsyncMock(side_effect=AssertionError("No network in writer")) + provider.get_issues_for_series = AsyncMock(side_effect=AssertionError("No network in writer")) + return MetadataService(provider, tmp_path), provider + + +@pytest.mark.parametrize("catalog", [False, True]) +async def test_prefetched_writers_persist_transport_provenance_and_parent( + identity_probe_db, tmp_path, catalog +): + _, factory, _ = identity_probe_db + service, provider = _service(tmp_path) + profile, summary = _profile(), _summary() + if catalog: + profile = CatalogSeriesMetadata(**asdict(profile)) + summary = CatalogIssueSummary(**asdict(summary)) + async with factory.begin() as session: + series = await service.upsert_series_metadata(session, 42, profile) + issues = await service.upsert_issue_summaries(session, series, [summary]) + series_id, issue_id = series.id, issues[0].id + async with factory() as session: + parent = await session.scalar(select(SeriesExternalIdentity)) + child = await session.scalar(select(IssueExternalIdentity)) + assert parent is not None and child is not None + assert (parent.series_id, child.issue_id, child.external_id) == (series_id, issue_id, "91") + for model in (SeriesIdentityEvent, IssueIdentityEvent): + record = await session.scalar(select(model)) + assert record.evidence_kind == IdentityEvidenceKind.PROVIDER_RESULT + assert ( + '"source_instance":"comicvine_local"' + if catalog + else '"source_instance":"comicvine_api"' + ) in record.request_json + assert ( + '"parent_identity"' in (await session.scalar(select(IssueIdentityEvent))).request_json + ) + assert (await session.get(Issue, issue_id)).comicvine_id == 91 + assert provider.mock_calls == [] + + +async def test_catalog_does_not_replace_live_fields_but_repairs_missing_generic_identity( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = Series( + comicvine_id=42, + title="Live title", + sort_title="Live title", + metadata_source="comicvine", + ) + session.add(series) + await session.flush() + issue = Issue( + series_id=series.id, + comicvine_id=91, + issue_number=1, + issue_number_text="1", + title="Live issue", + metadata_source="comicvine", + ) + session.add(issue) + await session.flush() + await service.upsert_series_metadata( + session, 42, CatalogSeriesMetadata(**asdict(_profile())) + ) + await service.upsert_issue_summaries( + session, series, [CatalogIssueSummary(**asdict(_summary()))] + ) + assert series.title == "Live title" and issue.title == "Live issue" + assert await session.scalar(select(func.count()).select_from(SeriesExternalIdentity)) == 1 + assert await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 1 + + +async def test_provider_id_disagreement_is_rejected_before_any_series_write( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + with pytest.raises(ValidationError): + await service.upsert_series_metadata(session, 42, _profile("43")) + assert await session.scalar(select(func.count()).select_from(Series)) == 0 + + +@pytest.mark.parametrize("mode", ["new", "existing"]) +async def test_series_identity_conflict_preserves_fields_and_leaves_no_partial_row( + identity_probe_db, tmp_path, mode +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + owner = Series( + title="Original", sort_title="Original", comicvine_id=42 if mode == "existing" else None + ) + session.add(owner) + await session.flush() + session.add( + SeriesExternalIdentity( + series_id=owner.id, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="43" if mode == "existing" else "42", + verification_state=IdentityVerificationState.VERIFIED, + evidence_kind=IdentityEvidenceKind.LEGACY_BACKFILL, + ) + ) + async with factory.begin() as session: + with pytest.raises(ValidationError): + await service.upsert_series_metadata(session, 42, _profile()) + assert await session.scalar(select(func.count()).select_from(Series)) == 1 + assert (await session.scalar(select(Series))).title == "Original" + assert await session.scalar(select(func.count()).select_from(SeriesIdentityEvent)) == 0 + + +async def test_provisional_issue_gets_exact_identity_without_losing_owned_status( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = Series(title="Imported", sort_title="Imported", comicvine_id=42) + session.add(series) + await session.flush() + issue = Issue( + series_id=series.id, issue_number=13, issue_number_text="13A", status=IssueStatus.OWNED + ) + session.add(issue) + await session.flush() + await service.upsert_issue_summaries( + session, series, [replace(_summary(), issue_number=13, issue_number_text="13a")] + ) + assert issue.status == IssueStatus.OWNED and issue.issue_number_text == "13A" + assert issue.comicvine_id == 91 + assert (await session.scalar(select(IssueExternalIdentity))).issue_id == issue.id + + +async def test_conflicting_issue_id_cannot_silently_replace_existing_owned_issue( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = Series(title="Imported", sort_title="Imported", comicvine_id=42) + session.add(series) + await session.flush() + issue = Issue( + series_id=series.id, + comicvine_id=92, + issue_number=1, + issue_number_text="1", + title="Keep this", + status=IssueStatus.OWNED, + ) + session.add(issue) + await session.flush() + series_id, issue_id = series.id, issue.id + async with factory.begin() as session: + series = await session.get(Series, series_id) + with pytest.raises(ValidationError): + await service.upsert_issue_summaries(session, series, [_summary()]) + issue = await session.get(Issue, issue_id) + assert ( + issue.comicvine_id == 92 + and issue.title == "Keep this" + and issue.status == IssueStatus.OWNED + ) + + +async def test_other_series_id_collision_keeps_provisional_row_without_false_attachment( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + other = Series(title="Other", sort_title="Other", comicvine_id=43) + target = Series(title="Target", sort_title="Target", comicvine_id=42) + session.add_all([other, target]) + await session.flush() + session.add(Issue(series_id=other.id, comicvine_id=91, issue_number=1)) + await session.flush() + created = await service.upsert_issue_summaries(session, target, [_summary()]) + assert created[0].comicvine_id is None + assert await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 0 + + +@pytest.mark.parametrize( + "source_type,method", + [(ImportSourceType.MYLAR3, "mylar3_cv_id"), (ImportSourceType.FILESYSTEM, "user_override")], +) +async def test_targeted_import_records_local_review_provenance_without_provider_calls( + identity_probe_db, tmp_path, source_type, method +): + _, factory, _ = identity_probe_db + metadata, provider = _service(tmp_path) + provider.get_series_cached = AsyncMock(return_value=None) + service = SeriesService(metadata_service=metadata, event_bus=EventBus()) + async with factory.begin() as session: + job = ImportJob(source_path="/fixture/mylar.db", source_type=source_type) + session.add(job) + await session.flush() + item = ImportedSeries( + import_job_id=job.id, raw_series_name="Trusted import", cv_id=42, cv_match_method=method + ) + session.add(item) + await session.flush() + await service.add_from_import_review_targeted( + session, import_series=item, issue_summaries=[_summary()] + ) + for model in (SeriesIdentityEvent, IssueIdentityEvent): + record = await session.scalar(select(model)) + assert record is not None + assert record.evidence_kind == ( + IdentityEvidenceKind.MYLAR_DATABASE + if method == "mylar3_cv_id" + else IdentityEvidenceKind.MIGRATION + ) + assert '"record_kind":"imported_series"' in record.request_json + assert '"source_instance"' not in record.request_json + assert provider.mock_calls == [] + + +async def test_writer_rollback_removes_metadata_and_identity_together(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory() as session: + series = await service.upsert_series_metadata(session, 42, _profile()) + await service.upsert_issue_summaries(session, series, [_summary()]) + await session.rollback() + async with factory() as session: + for model in ( + Series, + Issue, + SeriesExternalIdentity, + IssueExternalIdentity, + SeriesIdentityEvent, + IssueIdentityEvent, + ): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + + +async def test_bulk_hydration_attaches_with_bounded_statement_count(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = Series(title="Long running", sort_title="Long running", comicvine_id=42) + session.add(series) + await session.flush() + series_id = series.id + statements = [] + + def track(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + series = await session.get(Series, series_id) + created = await service.upsert_issue_summaries( + session, series, [_summary(str(i + 1000), i) for i in range(1, 451)] + ) + assert len(created) == 450 + assert ( + await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 450 + ) + # Existing ORM creation uses per-row INSERT RETURNING on SQLite. Count only SELECTs. + assert sum(statement.lstrip().upper().startswith("SELECT") for statement in statements) < 45 + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) + + +@pytest.mark.parametrize("kind", ["series", "issue", "parent"]) +async def test_new_hydration_cannot_bypass_later_conflicted_ownership( + identity_probe_db, + tmp_path, + kind, +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = await service.upsert_series_metadata(session, 42, _profile()) + await service.upsert_issue_summaries(session, series, [_summary()]) + series_id = series.id + model = IssueExternalIdentity if kind == "issue" else SeriesExternalIdentity + attachment = await session.scalar(select(model)) + attachment.verification_state = IdentityVerificationState.CONFLICTED + async with factory.begin() as session: + series = await session.get(Series, series_id) + with pytest.raises(ValidationError): + if kind == "series": + await service.upsert_series_metadata(session, 42, _profile()) + else: + await service.upsert_issue_summaries(session, series, [_summary()]) + assert await session.scalar(select(func.count()).select_from(SeriesIdentityEvent)) == 1 + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 1 + + +@pytest.mark.parametrize("record", ["missing", "unsaved", "different_match"]) +async def test_local_import_origin_must_still_match_saved_review( + identity_probe_db, tmp_path, record +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + job = ImportJob(source_path="/fixture/mylar.db", source_type=ImportSourceType.MYLAR3) + session.add(job) + await session.flush() + item = ImportedSeries(import_job_id=job.id, raw_series_name="Changed", cv_id=43) + session.add(item) + await session.flush() + origin = ImportIdentityOrigin( + None if record == "unsaved" else (item.id if record == "different_match" else 99999) + ) + with pytest.raises(ValidationError): + await service.upsert_series_metadata(session, 42, _profile(), identity_origin=origin) + assert await session.scalar(select(func.count()).select_from(Series)) == 0 + assert await session.scalar(select(func.count()).select_from(SeriesIdentityEvent)) == 0 + + +async def test_late_issue_conflict_rolls_back_all_prior_attachment_batches( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = await service.upsert_series_metadata(session, 42, _profile()) + session.add_all( + [Issue(series_id=series.id, issue_number=i, title="Preserve") for i in range(1, 450)] + ) + await session.flush() + issue = Issue(series_id=series.id, comicvine_id=999, issue_number=450, title="Preserve") + session.add(issue) + await session.flush() + series_id, issue_id = series.id, issue.id + async with factory.begin() as session: + series = await session.get(Series, series_id) + with pytest.raises(ValidationError): + await service.upsert_issue_summaries( + session, series, [_summary(str(i + 1000), i) for i in range(1, 451)] + ) + assert await session.scalar(select(func.count()).select_from(Issue)) == 450 + assert (await session.get(Issue, issue_id)).title == "Preserve" + assert ( + await session.scalar( + select(func.count()).select_from(Issue).where(Issue.title != "Preserve") + ) + == 0 + ) + assert await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 0 + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 0 + + +async def test_large_catalog_stays_under_conservative_bind_limit(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + # Keep ORM's configurable INSERT pages small too; explicit lookups and + # identity batches must remain bounded independently of ORM pagination. + engine.update_execution_options(insertmanyvalues_page_size=20) + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = await service.upsert_series_metadata(session, 42, _profile()) + series_id = series.id + + def bound(_conn, _cursor, _statement, params, _context, many): + batches = ( + params if many and params and isinstance(params[0], tuple | list | dict) else [params] + ) + assert all(len(batch) <= 999 for batch in batches) + + event.listen(engine.sync_engine, "before_cursor_execute", bound) + try: + async with factory.begin() as session: + series = await session.get(Series, series_id) + await service.upsert_issue_summaries( + session, series, [_summary(str(i + 1000), i) for i in range(1, 1201)] + ) + assert ( + await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) + == 1200 + ) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", bound) + + +async def test_hydration_locks_parent_before_updating_issue(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + if engine.dialect.name != "postgresql": + pytest.skip("SQLite serializes writers at transaction entry rather than row locks") + service, _ = _service(tmp_path) + async with factory.begin() as session: + series = await service.upsert_series_metadata(session, 42, _profile()) + await service.upsert_issue_summaries(session, series, [_summary()]) + series_id = series.id + statements = [] + + def track(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + series = await session.get(Series, series_id) + await service.upsert_issue_summaries( + session, series, [replace(_summary(), title="Updated")] + ) + parent_lock = next( + i for i, sql in enumerate(statements) if "FROM series " in sql and "FOR UPDATE" in sql + ) + child_write = next( + i for i, sql in enumerate(statements) if sql.startswith("UPDATE issues ") + ) + assert parent_lock < child_write + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) diff --git a/tests/unit/test_refresh_series_metadata.py b/tests/unit/test_refresh_series_metadata.py index 5e093c91..7fb96d6c 100644 --- a/tests/unit/test_refresh_series_metadata.py +++ b/tests/unit/test_refresh_series_metadata.py @@ -751,7 +751,8 @@ async def test_fetch_series_clears_stale_cover_cache_for_new_series( provider = MagicMock() provider.get_series = AsyncMock( - return_value=SimpleNamespace( + return_value=SeriesMetadata( + provider_id="160860", title="Absolute Martian Manhunter", sort_title="Absolute Martian Manhunter", year_start=2025, diff --git a/tests/unit/test_series_targeted_import.py b/tests/unit/test_series_targeted_import.py index 9f6278cd..a0f1f658 100644 --- a/tests/unit/test_series_targeted_import.py +++ b/tests/unit/test_series_targeted_import.py @@ -26,6 +26,7 @@ from pullbox.providers.base import IssueSummary, SeriesMetadata from pullbox.services import cover_cache_service from pullbox.services import series_service as series_service_module +from pullbox.services.metadata_writer_identity import ImportIdentityOrigin from pullbox.services.series_service import SeriesService if TYPE_CHECKING: @@ -36,7 +37,10 @@ async def _fake_upsert_series( session: AsyncSession, cv_id: int, meta: SeriesMetadata, + *, + identity_origin: ImportIdentityOrigin | None = None, ) -> Series: + assert identity_origin is None or isinstance(identity_origin, ImportIdentityOrigin) publisher: Publisher | None = None if meta.publisher: publisher = await session.scalar(select(Publisher).where(Publisher.name == meta.publisher)) @@ -103,7 +107,9 @@ async def _fake_upsert_issue_summaries( summaries: list[IssueSummary], *, infer_series_type_from_summaries: bool = False, + identity_origin: ImportIdentityOrigin | None = None, ) -> list[Issue]: + assert identity_origin is None or isinstance(identity_origin, ImportIdentityOrigin) del infer_series_type_from_summaries created: list[Issue] = [] for summary in summaries: From 9de2dfaa3b421025e98b824609df4ddee8f5f732 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 22:05:20 -0700 Subject: [PATCH 12/97] fix: guard metadata refresh and recovery identity ownership --- .../services/import_reference_recovery.py | 351 ++++++++++------- .../services/metadata_identity_attachment.py | 57 ++- src/pullbox/services/metadata_service.py | 36 +- .../services/metadata_writer_identity.py | 30 +- .../services/story_arc_catalog_persistence.py | 2 +- .../test_attachment_service.py | 71 ++++ .../test_remaining_writer_integration.py | 352 ++++++++++++++++++ 7 files changed, 740 insertions(+), 159 deletions(-) create mode 100644 tests/integration/metadata_identity/test_remaining_writer_integration.py diff --git a/src/pullbox/services/import_reference_recovery.py b/src/pullbox/services/import_reference_recovery.py index e60edb9d..cad50cff 100644 --- a/src/pullbox/services/import_reference_recovery.py +++ b/src/pullbox/services/import_reference_recovery.py @@ -12,7 +12,7 @@ from sqlalchemy import and_, or_, select -from pullbox.core.exceptions import ConfigurationError +from pullbox.core.exceptions import ConfigurationError, ValidationError from pullbox.core.library_file_ownership import build_file_identity_signature from pullbox.core.name_matcher import NameMatcher from pullbox.models.import_job import ( @@ -37,6 +37,10 @@ catalog_target_agrees, record_catalog_review, ) +from pullbox.services.metadata_writer_identity import ( + attach_issue_summary_identities, + metadata_write_scope, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -206,10 +210,6 @@ async def repair_catalog_references( progress: Callable[[int, int], Awaitable[None]] | None = None, ) -> int: """Create missing metadata targets and reuse ownership repair, never file import.""" - from pullbox.services.import_completed_cleanup import ( - _apply_mixed_folder_resolutions, - _MixedFolderResolution, - ) from pullbox.services.import_workflow_state import raise_if_job_cancelled current = await reference_candidates(session, job.id) @@ -276,143 +276,29 @@ async def repair_catalog_references( or root.path != inspected_root_path ): continue - cv_id = int(target["cv_id"]) - issue_cv_id = int(target["summary"]["provider_id"]) - series = await session.scalar(select(Series).where(Series.comicvine_id == cv_id)) - created_series = series is None - issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id)) - reparent_issue = issue is not None and (series is None or issue.series_id != series.id) - if reparent_issue and ( - issue is None - or issue.id != library.issue_id - or issue.series_id != item.series_id - or identity.get("issue_cv_id") != issue.comicvine_id - or identity["evidence"] != "comicinfo" - ): - continue - if issue is not None and ( - issue.effective_issue_number_text != identity["issue_number"] - or issue.issue_type.value != str(target["summary"].get("issue_type") or "issue") - ): - continue - if issue is not None and await session.scalar( - select(LibraryFile.id).where( - LibraryFile.issue_id == issue.id, LibraryFile.id != library.id - ) - ): - continue - if ( - reparent_issue - and issue is not None - and await session.scalar( - select(ImportedFile.id).where( - ImportedFile.matched_issue_id == issue.id, - ImportedFile.id != file.id, - ImportedFile.status == ImportedFileStatus.IMPORTED, + try: + async with metadata_write_scope(session): + changed = await _repair_reference( + session, + job, + metadata, + file, + item, + library, + root, + inspected_root_path, + identity, + target, ) - ) - ): + except ValidationError as exc: + if (exc.details or {}).get("reason") != "metadata_identity_conflict": + raise + await session.refresh(file) + record_catalog_review(file, identity, "Identity ownership needs review before repair.") + await session.commit() continue - if await session.scalar( - select(ImportedFile.id).where( - ImportedFile.matched_issue_cv_id == issue_cv_id, - ImportedFile.include_in_import.is_(True), - ImportedFile.status.in_((ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED)), - ) - ): + if not changed: continue - if series is not None and (issue is None or reparent_issue): - existing = list( - await session.scalars(select(Issue).where(Issue.series_id == series.id)) - ) - if any(row.effective_issue_number_text == identity["issue_number"] for row in existing): - continue - if series is None: - series = await metadata.upsert_series_metadata( - session, - cv_id, - CatalogSeriesMetadata( - provider_id=str(cv_id), - title=str(target["title"]), - sort_title=str(target["title"]), - year_start=target.get("year"), - year_end=None, - status=None, - publisher=target.get("publisher"), - description=None, - cover_url=target.get("cover_url"), - issue_count=target.get("issue_count"), - comicvine_url=target.get("comicvine_url"), - ), - ) - series.monitored = False - series.issue_catalog_state = IssueCatalogState.PARTIAL - previous_series_id = issue.series_id if reparent_issue and issue is not None else None - if reparent_issue and issue is not None: - # The exact embedded identity and catalog agree, and this is the - # only registration. Retain the issue ID (including reader state). - issue.series_id = series.id - if issue is None: - payload = target["summary"] - cutoff = payload.get("source_cutoff_at") - summary = CatalogIssueSummary( - source_cutoff_at=datetime.fromisoformat(cutoff) if cutoff else None, - **{ - key: payload[key] for key in IssueSummary.__dataclass_fields__ if key in payload - }, - ) - await metadata.upsert_issue_summaries(session, series, [summary]) - issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id)) - if issue is None or issue.series_id != series.id: - continue - resolution = _MixedFolderResolution( - file_id=file.id, - source_import_series_id=item.id, - source_import_series_name=item.raw_series_name, - target_series_id=series.id, - target_series_title=series.title, - target_issue_id=issue.id, - target_issue_cv_id=issue.comicvine_id, - target_issue_number=issue.issue_number, - target_issue_number_text=issue.effective_issue_number_text, - target_library_file_id=None, - source_library_file_id=library.id, - source_issue_id=library.issue_id, - source_library_updated_at=library.updated_at.isoformat(), - evidence_source=identity["evidence"], - source_series_name=identity["query"], - source_updated_at=file.updated_at.isoformat(), - ) - affected, _ = await _apply_mixed_folder_resolutions(session, job, resolutions=(resolution,)) - if previous_series_id is not None: - file.diagnostics = { - **file.diagnostics, - "completed_import_cleanup": { - **file.diagnostics["completed_import_cleanup"], - "source_catalog_series_id": previous_series_id, - "issue_identity_preserved": True, - }, - } - previous_issue = await session.get(Issue, resolution.source_issue_id) - if ( - previous_issue is not None - and previous_issue.comicvine_id is None - and previous_issue.metadata_source in {"provisional_import", "import_placeholder"} - and not await session.scalar( - select(LibraryFile.id).where(LibraryFile.issue_id == previous_issue.id) - ) - ): - # Keep the audit/reader row, but do not search for an issue invented - # from a misplaced filename under the wrong series. - previous_issue.status = IssueStatus.SKIPPED - if created_series: - target_group = await session.get(ImportedSeries, file.import_series_id) - assert target_group is not None - target_group.status = ImportSeriesStatus.IMPORTED - apply_proven_identity( - file, issue_cv_id=issue_cv_id, series_cv_id=cv_id, summary=target["summary"] - ) - await refresh_recovered_groups(session, job, affected) repaired += 1 state = dict(job.progress_snapshot.get("deferred_recovery") or {}) state["reference_files_repaired"] = repaired @@ -421,3 +307,190 @@ async def repair_catalog_references( if progress is not None and plans: await progress(len(plans), len(plans)) return repaired + + +async def _repair_reference( + session: AsyncSession, + job: ImportJob, + metadata: MetadataService, + file: ImportedFile, + item: ImportedSeries, + library: LibraryFile, + root: LibraryRoot, + inspected_root_path: str, + identity: dict[str, Any], + target: dict[str, Any], +) -> bool: + """Keep assignment and identity evidence in the same per-file savepoint.""" + from pullbox.services.import_completed_cleanup import ( + _apply_mixed_folder_resolutions, + _MixedFolderResolution, + ) + + cv_id = int(target["cv_id"]) + issue_cv_id = int(target["summary"]["provider_id"]) + series = await session.scalar(select(Series).where(Series.comicvine_id == cv_id)) + issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id)) + source_issue = await session.get(Issue, library.issue_id) + expected_parents = {row.id: row.series_id for row in (issue, source_issue) if row is not None} + parent_ids = set(expected_parents.values()) + if series is not None: + parent_ids.add(series.id) + await session.execute( + select(Series.id).where(Series.id.in_(parent_ids)).order_by(Series.id).with_for_update() + ) + locked = list( + await session.scalars( + select(Issue) + .where(Issue.id.in_(expected_parents)) + .order_by(Issue.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ) + if len(locked) != len(expected_parents) or any( + row.series_id != expected_parents[row.id] for row in locked + ): + return False + for row in (file, item, library, root): + await session.refresh(row) + if ( + _stamp(file, item, library) != identity["stamp"] + or not root.enabled + or not root.allow_referenced_registrations + or root.path != inspected_root_path + ): + return False + + created_series = series is None + reparent_issue = issue is not None and (series is None or issue.series_id != series.id) + if reparent_issue and ( + issue is None + or issue.id != library.issue_id + or issue.series_id != item.series_id + or identity.get("issue_cv_id") != issue.comicvine_id + or identity["evidence"] != "comicinfo" + ): + return False + if issue is not None and ( + issue.effective_issue_number_text != identity["issue_number"] + or issue.issue_type.value != str(target["summary"].get("issue_type") or "issue") + ): + return False + if issue is not None and await session.scalar( + select(LibraryFile.id).where(LibraryFile.issue_id == issue.id, LibraryFile.id != library.id) + ): + return False + if ( + reparent_issue + and issue is not None + and await session.scalar( + select(ImportedFile.id).where( + ImportedFile.matched_issue_id == issue.id, + ImportedFile.id != file.id, + ImportedFile.status == ImportedFileStatus.IMPORTED, + ) + ) + ): + return False + if await session.scalar( + select(ImportedFile.id).where( + ImportedFile.matched_issue_cv_id == issue_cv_id, + ImportedFile.include_in_import.is_(True), + ImportedFile.status.in_((ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED)), + ) + ): + return False + if series is not None and (issue is None or reparent_issue): + existing = list(await session.scalars(select(Issue).where(Issue.series_id == series.id))) + if any(row.effective_issue_number_text == identity["issue_number"] for row in existing): + return False + if series is None: + series = await metadata.upsert_series_metadata( + session, + cv_id, + CatalogSeriesMetadata( + provider_id=str(cv_id), + title=str(target["title"]), + sort_title=str(target["title"]), + year_start=target.get("year"), + year_end=None, + status=None, + publisher=target.get("publisher"), + description=None, + cover_url=target.get("cover_url"), + issue_count=target.get("issue_count"), + comicvine_url=target.get("comicvine_url"), + ), + ) + series.monitored = False + series.issue_catalog_state = IssueCatalogState.PARTIAL + previous_series_id = issue.series_id if reparent_issue and issue is not None else None + if reparent_issue and issue is not None: + # Preserve the issue ID and reader state when exact embedded and catalog + # identities agree. The surrounding savepoint also encloses its history. + issue.series_id = series.id + payload = target["summary"] + cutoff = payload.get("source_cutoff_at") + summary = CatalogIssueSummary( + source_cutoff_at=datetime.fromisoformat(cutoff) if cutoff else None, + **{key: payload[key] for key in IssueSummary.__dataclass_fields__ if key in payload}, + ) + if issue is None: + await metadata.upsert_issue_summaries(session, series, [summary]) + issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id)) + else: + await attach_issue_summary_identities(session, series, [(issue, summary)], None) + if issue is None or issue.series_id != series.id: + raise ValidationError( + "The recovery target changed. Review the match before retrying.", + details={"reason": "metadata_identity_conflict"}, + ) + resolution = _MixedFolderResolution( + file_id=file.id, + source_import_series_id=item.id, + source_import_series_name=item.raw_series_name, + target_series_id=series.id, + target_series_title=series.title, + target_issue_id=issue.id, + target_issue_cv_id=issue.comicvine_id, + target_issue_number=issue.issue_number, + target_issue_number_text=issue.effective_issue_number_text, + target_library_file_id=None, + source_library_file_id=library.id, + source_issue_id=library.issue_id, + source_library_updated_at=library.updated_at.isoformat(), + evidence_source=identity["evidence"], + source_series_name=identity["query"], + source_updated_at=file.updated_at.isoformat(), + ) + affected, _ = await _apply_mixed_folder_resolutions(session, job, resolutions=(resolution,)) + if previous_series_id is not None: + file.diagnostics = { + **file.diagnostics, + "completed_import_cleanup": { + **file.diagnostics["completed_import_cleanup"], + "source_catalog_series_id": previous_series_id, + "issue_identity_preserved": True, + }, + } + previous_issue = await session.get(Issue, resolution.source_issue_id) + if ( + previous_issue is not None + and previous_issue.comicvine_id is None + and previous_issue.metadata_source in {"provisional_import", "import_placeholder"} + and not await session.scalar( + select(LibraryFile.id).where(LibraryFile.issue_id == previous_issue.id) + ) + ): + # Keep audit/reader state without searching for an invented issue. + previous_issue.status = IssueStatus.SKIPPED + if created_series: + target_group = await session.get(ImportedSeries, file.import_series_id) + assert target_group is not None + target_group.status = ImportSeriesStatus.IMPORTED + apply_proven_identity( + file, issue_cv_id=issue_cv_id, series_cv_id=cv_id, summary=target["summary"] + ) + await refresh_recovered_groups(session, job, affected) + return True diff --git a/src/pullbox/services/metadata_identity_attachment.py b/src/pullbox/services/metadata_identity_attachment.py index b0a6c894..0603f05d 100644 --- a/src/pullbox/services/metadata_identity_attachment.py +++ b/src/pullbox/services/metadata_identity_attachment.py @@ -47,13 +47,18 @@ class IdentityAttachmentReceipt: async def attach_verified_identities( - session: AsyncSession, requests: Sequence[IdentityEventRequest] + session: AsyncSession, + requests: Sequence[IdentityEventRequest], + *, + require_current_ownership: bool = False, ) -> list[IdentityAttachmentReceipt]: """Attach adapter-validated exact evidence in the caller's transaction. This is not a transport API or an authorization boundary. Adapters must establish evidence trust before calling it. Explicit user decisions use a separate review path; this path must never override their retained history. + Consumers that depend on present ownership must opt in to replay validation; + an ordinary receipt proves only that the historical operation succeeded. """ if not requests: return [] @@ -101,7 +106,12 @@ async def attach_verified_identities( group = list(members) for offset in range(0, len(group), _BATCH_SIZE): receipts.update( - await _attach_batch(session, kind, group[offset : offset + _BATCH_SIZE]) + await _attach_batch( + session, + kind, + group[offset : offset + _BATCH_SIZE], + require_current_ownership=require_current_ownership, + ) ) except IntegrityError as exc: raise IdentityAttachmentConflictError( @@ -191,7 +201,11 @@ async def _locked_targets( async def _attach_batch( - session: AsyncSession, kind: MetadataEntityKind, requests: list[IdentityEventRequest] + session: AsyncSession, + kind: MetadataEntityKind, + requests: list[IdentityEventRequest], + *, + require_current_ownership: bool, ) -> dict[str, IdentityAttachmentReceipt]: target_key = f"{kind.value}_id" active = Base.metadata.tables[f"{kind.value}_external_identities"] @@ -234,7 +248,8 @@ async def _attach_batch( fresh = [ request for request in requests if prepare_identity_event(request).event_key not in receipts ] - if not fresh: + checked = requests if require_current_ownership else fresh + if not checked: return receipts active_rows = ( @@ -242,14 +257,14 @@ async def _attach_batch( await session.execute( select(active).where( or_( - active.c[target_key].in_([request.local_id for request in fresh]), + active.c[target_key].in_([request.local_id for request in checked]), tuple_(active.c.identity_namespace, active.c.external_id).in_( [ ( request.evidence.claim.identity.namespace, request.evidence.claim.identity.external_id, ) - for request in fresh + for request in checked ] ), ) @@ -272,7 +287,7 @@ async def _attach_batch( request.evidence.claim.identity.namespace, request.evidence.claim.identity.external_id, ) - for request in fresh + for request in checked ] ) ) @@ -284,9 +299,33 @@ async def _attach_batch( await session.execute(select(history).where(history.c.id.in_(latest_ids))) ).mappings() } - await _validate_legacy_owners(session, kind, fresh, targets) + await _validate_legacy_owners(session, kind, checked, targets) if kind is MetadataEntityKind.ISSUE: - await _validate_issue_parents(session, fresh, targets) + await _validate_issue_parents(session, checked, targets) + + if require_current_ownership: + for request in requests: + if prepare_identity_event(request).event_key not in receipts: + continue + identity = request.evidence.claim.identity + target = (request.local_id, identity.namespace) + existing = by_target.get(target) + if ( + existing is None + or existing.external_id != identity.external_id + or existing.verification_state is not IdentityVerificationState.VERIFIED + or decisions.get((*target, identity.external_id)) + is not IdentityVerificationState.VERIFIED + or ( + identity.namespace is IdentityNamespace.COMICVINE + and targets[request.local_id].comicvine_id != int(identity.external_id) + ) + ): + raise IdentityReviewRequiredError( + "Historical identity receipt no longer proves current ownership" + ) + if not fresh: + return receipts now = datetime.now(UTC) creates: dict[tuple[int, IdentityNamespace], dict[str, object]] = {} diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py index b06cab90..66245829 100644 --- a/src/pullbox/services/metadata_service.py +++ b/src/pullbox/services/metadata_service.py @@ -40,6 +40,7 @@ from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata from pullbox.services.cover_cache_service import purge_series_cover_cache from pullbox.services.metadata_writer_identity import ( + attach_issue_metadata_identity, attach_issue_summary_identities, attach_series_metadata_identity, comicvine_identity, @@ -623,11 +624,35 @@ async def fetch_issue( except ComicVineError as exc: raise _provider_error_from_comicvine(exc) from exc - existing = ( - await session.execute(select(Issue).where(Issue.comicvine_id == comicvine_id)) - ).scalar_one_or_none() + identity = comicvine_identity(MetadataEntityKind.ISSUE, meta.provider_id) + if int(identity.external_id) != comicvine_id: + raise ValidationError("The returned metadata belongs to a different ComicVine issue.") + target = ( + await session.execute( + select(Issue.id, Issue.series_id).where(Issue.comicvine_id == comicvine_id) + ) + ).one_or_none() + if target is None: + raise NotFoundError("Issue", comicvine_id) - if existing: + async with metadata_write_scope(session): + # Match attachment lock order: parent before child, and recheck the + # relationship after locking in case recovery moved it meanwhile. + series = await session.scalar( + select(Series) + .where(Series.id == target.series_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + existing = await session.scalar( + select(Issue) + .where(Issue.id == target.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + if series is None or existing is None or existing.series_id != series.id: + raise ValidationError("The issue changed during refresh. Reload before retrying.") + await attach_issue_metadata_identity(session, series, existing, meta) existing.issue_number = meta.issue_number existing.issue_number_text = _exact_issue_number_text( meta.issue_number, @@ -646,9 +671,6 @@ async def fetch_issue( existing.metadata_source = "comicvine" issue = existing log.debug("metadata_issue_updated", issue_id=issue.id) - else: - raise NotFoundError("Issue", comicvine_id) - return issue async def prefetch_issue_metadata_batch( diff --git a/src/pullbox/services/metadata_writer_identity.py b/src/pullbox/services/metadata_writer_identity.py index fdd732bb..b23dae26 100644 --- a/src/pullbox/services/metadata_writer_identity.py +++ b/src/pullbox/services/metadata_writer_identity.py @@ -42,12 +42,12 @@ ) if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Sequence from sqlalchemy.ext.asyncio import AsyncSession from pullbox.models import Issue - from pullbox.providers.base import IssueSummary, SeriesMetadata + from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesMetadata @dataclass(frozen=True) @@ -134,7 +134,7 @@ def _request( operation: UUID, local_id: int, identity: ExternalIdentityRef, - metadata: SeriesMetadata | IssueSummary | None, + metadata: SeriesMetadata | IssueMetadata | IssueSummary | None, local: _LocalEvidence | None, parent: ExternalIdentityRef | None = None, ) -> IdentityEventRequest: @@ -194,6 +194,30 @@ async def attach_issue_summary_identities( series: Series, members: list[tuple[Issue, IssueSummary]], origin: ImportIdentityOrigin | None, +) -> None: + await _attach_issue_identities(session, series, members, origin) + + +async def attach_issue_metadata_identity( + session: AsyncSession, + series: Series, + issue: Issue, + metadata: IssueMetadata, +) -> None: + parent = comicvine_identity(MetadataEntityKind.SERIES, metadata.series_provider_id) + identity = comicvine_identity(MetadataEntityKind.ISSUE, metadata.provider_id) + if series.comicvine_id != int(parent.external_id) or issue.comicvine_id != int( + identity.external_id + ): + raise IdentityAttachmentConflictError("Full issue metadata disagrees with its local target") + await _attach_issue_identities(session, series, [(issue, metadata)], None) + + +async def _attach_issue_identities( + session: AsyncSession, + series: Series, + members: Sequence[tuple[Issue, IssueSummary | IssueMetadata]], + origin: ImportIdentityOrigin | None, ) -> None: if not members: return diff --git a/src/pullbox/services/story_arc_catalog_persistence.py b/src/pullbox/services/story_arc_catalog_persistence.py index 3d0f6847..a0360d0b 100644 --- a/src/pullbox/services/story_arc_catalog_persistence.py +++ b/src/pullbox/services/story_arc_catalog_persistence.py @@ -197,7 +197,7 @@ async def _attach_member_identities( ) ) try: - await attach_verified_identities(session, requests) + await attach_verified_identities(session, requests, require_current_ownership=True) except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc: raise StoryArcCatalogError( "identity_conflict", "Canonical identity needs review before adding these members" diff --git a/tests/integration/metadata_identity/test_attachment_service.py b/tests/integration/metadata_identity/test_attachment_service.py index 0f892ae9..4deec7bb 100644 --- a/tests/integration/metadata_identity/test_attachment_service.py +++ b/tests/integration/metadata_identity/test_attachment_service.py @@ -185,6 +185,77 @@ async def test_replay_does_not_restore_released_ownership(identity_probe_db): assert await _counts(factory) == (0, 1) +@pytest.mark.parametrize("problem", [None, "released", "stale", "conflicted", "rejected", "legacy"]) +async def test_current_ownership_replay_does_not_accept_historical_receipt_alone( + identity_probe_db, + problem, +): + _, factory, _ = identity_probe_db + first, _ = await _series(factory) + request = _request(first) + async with factory.begin() as session: + receipt = (await attach_verified_identities(session, [request]))[0] + if problem == "released": + await session.execute(delete(SeriesExternalIdentity)) + elif problem in {"stale", "conflicted"}: + await session.execute(update(SeriesExternalIdentity).values(verification_state=problem)) + elif problem == "legacy": + await session.execute(update(Series).values(comicvine_id=None)) + elif problem == "rejected": + later = prepare_identity_event(replace(request, operation_id=uuid4())) + session.add( + SeriesIdentityEvent( + series_id=first, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="42", + verification_state=IdentityVerificationState.REJECTED, + evidence_kind=IdentityEvidenceKind.PROVIDER_RESULT, + event_key=later.event_key, + request_fingerprint=later.request_fingerprint, + request_json=later.request_json, + ) + ) + async with factory.begin() as session: + if problem: + with pytest.raises((IdentityAttachmentConflictError, IdentityReviewRequiredError)): + await attach_verified_identities(session, [request], require_current_ownership=True) + else: + replay = ( + await attach_verified_identities(session, [request], require_current_ownership=True) + )[0] + assert replay.replayed and replay.event_id == receipt.event_id + assert await session.scalar(select(func.count()).select_from(SeriesIdentityEvent)) == ( + 2 if problem == "rejected" else 1 + ) + + +@pytest.mark.parametrize("problem", ["parent", "parent_state", "issue_state", "issue_legacy"]) +async def test_current_issue_replay_rechecks_parent_and_issue(identity_probe_db, problem): + _, factory, _ = identity_probe_db + first, second = await _series(factory) + parent = ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42") + async with factory.begin() as session: + await attach_verified_identities(session, [_request(first), _request(second, "43")]) + issue = Issue(series_id=first, issue_number=1) + session.add(issue) + await session.flush() + request = _request(issue.id, "91", kind=MetadataEntityKind.ISSUE, parent=parent) + await attach_verified_identities(session, [request]) + if problem == "parent": + issue.series_id = second + elif problem == "parent_state": + await session.execute(update(SeriesExternalIdentity).values(verification_state="stale")) + elif problem == "issue_legacy": + issue.comicvine_id = None + else: + await session.execute( + update(IssueExternalIdentity).values(verification_state="conflicted") + ) + async with factory.begin() as session: + with pytest.raises((IdentityAttachmentConflictError, IdentityReviewRequiredError)): + await attach_verified_identities(session, [request], require_current_ownership=True) + + async def test_replay_rejects_changed_parent_or_corrupt_payload(identity_probe_db): _, factory, _ = identity_probe_db first, _ = await _series(factory) diff --git a/tests/integration/metadata_identity/test_remaining_writer_integration.py b/tests/integration/metadata_identity/test_remaining_writer_integration.py new file mode 100644 index 00000000..778d841a --- /dev/null +++ b/tests/integration/metadata_identity/test_remaining_writer_integration.py @@ -0,0 +1,352 @@ +"""Full refresh, arc retry and in-place repair preserve current identity ownership.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import delete, event, func, select, update + +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import IdentityEvidenceKind, IdentityNamespace +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, +) +from pullbox.models.reader import IssueReaderState +from pullbox.models.user import User +from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesMetadata +from pullbox.services.import_deferred_recovery_execution import prepare_deferred_recovery +from pullbox.services.metadata_service import MetadataService +from pullbox.services.metadata_writer_identity import attach_issue_summary_identities +from pullbox.services.story_arc_catalog import StoryArcCatalogService +from pullbox.services.story_arc_catalog_types import StoryArcCatalogError +from tests.unit.test_import_reference_recovery import add_second_reference, reference_case +from tests.unit.test_story_arc_catalog import _provider, _root + + +def _full(): + return IssueMetadata( + "91", + "42", + 13, + "Updated", + "Description", + "2020-01-01", + None, + None, + 64, + None, + issue_number_text="13A", + ) + + +async def _seed(factory, tmp_path): + provider = MagicMock() + provider.get_issue = AsyncMock(return_value=_full()) + service = MetadataService(provider, tmp_path) + async with factory.begin() as session: + series = await service.upsert_series_metadata( + session, + 42, + SeriesMetadata( + "42", + "Parent", + None, + 2020, + None, + None, + None, + None, + None, + 1, + None, + ), + ) + issue = ( + await service.upsert_issue_summaries( + session, + series, + [ + IssueSummary( + "91", + 13, + "Original", + None, + None, + "issue", + "13A", + ) + ], + ) + )[0] + issue.status = IssueStatus.OWNED + return service, provider, series.id, issue.id + + +@pytest.mark.parametrize("problem", [None, "id", "parent", "issue_conflict", "parent_conflict"]) +async def test_full_issue_refresh_requires_exact_current_identity( + identity_probe_db, tmp_path, problem +): + _, factory, _ = identity_probe_db + service, provider, series_id, issue_id = await _seed(factory, tmp_path) + if problem in {"id", "parent"}: + provider.get_issue.return_value = replace( + _full(), **{"provider_id" if problem == "id" else "series_provider_id": "99"} + ) + elif problem: + model = IssueExternalIdentity if problem == "issue_conflict" else SeriesExternalIdentity + async with factory.begin() as session: + await session.execute(update(model).values(verification_state="conflicted")) + async with factory.begin() as session: + if problem: + with pytest.raises(ValidationError): + await service.fetch_issue(session, 91) + else: + await service.fetch_issue(session, 91) + async with factory() as session: + issue = await session.get(Issue, issue_id) + assert issue.title == ("Original" if problem else "Updated") + assert issue.series_id == series_id and issue.status == IssueStatus.OWNED + assert issue.issue_number_text == "13A" + history = list( + await session.scalars(select(IssueIdentityEvent).order_by(IssueIdentityEvent.id)) + ) + assert len(history) == (1 if problem else 2) + assert '"parent_identity"' in history[-1].request_json + provider.get_issue.assert_awaited_once_with("91") + assert len(provider.mock_calls) == 1 + + +async def test_full_issue_refresh_has_no_write_lock_during_fetch_and_never_commits( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, provider, _, issue_id = await _seed(factory, tmp_path) + async with factory() as session: + + async def fetch(_id): + assert not session.in_transaction() + return _full() + + provider.get_issue.side_effect = fetch + await service.fetch_issue(session, 91) + await session.rollback() + async with factory() as session: + assert (await session.get(Issue, issue_id)).title == "Original" + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 1 + + +@pytest.mark.parametrize("kind", ["series", "issue"]) +@pytest.mark.parametrize("state", ["released", "stale", "conflicted"]) +async def test_arc_refresh_cannot_reuse_receipt_after_ownership_changes( + identity_probe_db, tmp_path, kind, state +): + _, factory, _ = identity_probe_db + provider = _provider() + service = StoryArcCatalogService(provider) + preview = await service.preview("31") + async with factory.begin() as session: + root = await _root(session, tmp_path) + result = await service.add( + session, preview, ordered_issue_provider_ids=["11", "12"], library_root_id=root.id + ) + arc_id, revision = result.id, result.revision + model = SeriesExternalIdentity if kind == "series" else IssueExternalIdentity + if state == "released": + await session.execute(delete(model)) + else: + await session.execute(update(model).values(verification_state=state)) + async with factory.begin() as session: + with pytest.raises(StoryArcCatalogError, match="identity needs review"): + await service.refresh(session, arc_id, preview, expected_revision=revision) + from pullbox.models.story_arc import StoryArc + + assert (await session.get(StoryArc, arc_id)).revision == revision + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 2 + + +@pytest.mark.parametrize("conflict", [None, "issue", "series"]) +async def test_reference_reparent_retains_owned_issue_and_updates_proof_atomically( + identity_probe_db, tmp_path, conflict +): + _, factory, _ = identity_probe_db + async with factory() as session: + job, file, library, issue, metadata, path = await reference_case(session, tmp_path) + original_parent_id, issue_id = issue.series_id, issue.id + reader = User(username="identity-reader", password_hash="unused-test-hash") + session.add(reader) + await session.flush() + reader_state = IssueReaderState( + user_id=reader.id, + issue_id=issue.id, + last_page_index=13, + page_count=30, + content_revision="a" * 64, + want_to_read=True, + ) + session.add(reader_state) + issue.comicvine_id = file.comicvine_issue_id = file.matched_issue_cv_id = 7001 + file.diagnostics = { + "source_metadata": {"comicinfo": {"series": "Thunderbolts", "number": "104"}}, + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + "comicvine_issue_id": "comicinfo", + }, + } + issue.title = "Keep local title" + parent = await session.get(Series, original_parent_id) + await attach_issue_summary_identities( + session, + parent, + [ + ( + issue, + IssueSummary( + "7001", + 104, + None, + None, + None, + "issue", + "104", + ), + ) + ], + None, + ) + if conflict == "issue": + await session.execute( + update(IssueExternalIdentity).values(verification_state="conflicted") + ) + elif conflict == "series": + target = Series(title="Thunderbolts", sort_title="Thunderbolts", comicvine_id=700) + session.add(target) + await session.flush() + session.add( + SeriesExternalIdentity( + series_id=target.id, + identity_namespace=IdentityNamespace.COMICVINE, + external_id="700", + verification_state=IdentityVerificationState.CONFLICTED, + evidence_kind=IdentityEvidenceKind.LEGACY_BACKFILL, + ) + ) + await session.commit() + before = path.read_bytes(), path.stat().st_mtime_ns + await prepare_deferred_recovery(session, job.id, metadata_service=metadata) + await session.refresh(issue) + await session.refresh(file) + await session.refresh(library) + assert issue.id == library.issue_id == file.matched_issue_id == issue_id + assert issue.title == "Keep local title" and issue.status == IssueStatus.OWNED + await session.refresh(reader_state) + assert reader_state.issue_id == issue_id and reader_state.last_page_index == 13 + assert reader_state.page_count == 30 and reader_state.content_revision == "a" * 64 + assert reader_state.want_to_read and reader_state.state_version == 1 + assert metadata._provider.mock_calls == [] + assert (path.read_bytes(), path.stat().st_mtime_ns) == before + history = list( + await session.scalars(select(IssueIdentityEvent).order_by(IssueIdentityEvent.id)) + ) + if conflict: + assert issue.series_id == original_parent_id + assert len(history) == 1 + assert "review" in file.diagnostics["mixed_folder_recovery"]["reason"].lower() + if conflict == "issue": + assert ( + await session.scalar(select(Series).where(Series.comicvine_id == 700)) is None + ) + else: + assert issue.series_id != original_parent_id + assert len(history) == 2 + assert '"external_id":"700"' in history[-1].request_json + assert '"source_instance":"comicvine_local"' in history[-1].request_json + + +async def test_reference_identity_conflict_does_not_block_later_safe_repair( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with factory() as session: + job, file, library, issue, metadata, path = await reference_case(session, tmp_path) + original_parent = issue.series_id + issue.comicvine_id = file.comicvine_issue_id = file.matched_issue_cv_id = 7001 + file.diagnostics = { + "source_metadata": {"comicinfo": {"series": "Thunderbolts", "number": "104"}}, + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + "comicvine_issue_id": "comicinfo", + }, + } + await attach_issue_summary_identities( + session, + await session.get(Series, original_parent), + [ + (issue, IssueSummary("7001", 104, None, None, None, "issue", "104")), + ], + None, + ) + await session.execute(update(IssueExternalIdentity).values(verification_state="conflicted")) + other, other_library = await add_second_reference(session, job, file, library, tmp_path) + other_issue = Issue( + series_id=original_parent, comicvine_id=1002, issue_number=105, status=IssueStatus.OWNED + ) + session.add(other_issue) + await session.flush() + other.matched_issue_id = other_library.issue_id = other_issue.id + other.matched_issue_cv_id = other_issue.comicvine_id + other.diagnostics = {"metadata_signals": {"issue_number": "release_title"}} + first = metadata.get_catalog_issue_summaries_for_series.return_value[0] + metadata.get_catalog_issue_summaries_for_series.return_value = [ + first, + replace(first, provider_id="7002", issue_number=105, issue_number_text="105"), + ] + await session.commit() + before = path.read_bytes(), path.stat().st_mtime_ns + await prepare_deferred_recovery(session, job.id, metadata_service=metadata) + await session.refresh(file) + await session.refresh(issue) + await session.refresh(other) + await session.refresh(other_library) + assert issue.series_id == original_parent + assert "review" in file.diagnostics["mixed_folder_recovery"]["reason"].lower() + recovered = await session.scalar(select(Issue).where(Issue.comicvine_id == 7002)) + assert recovered is not None and recovered.status is IssueStatus.OWNED + assert other.matched_issue_id == other_library.issue_id == recovered.id + assert (path.read_bytes(), path.stat().st_mtime_ns) == before + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 2 + assert job.progress_snapshot["deferred_recovery"]["reference_files_repaired"] == 1 + + +async def test_full_issue_refresh_locks_parent_before_child_write(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + if engine.dialect.name != "postgresql": + pytest.skip("SQLite serializes writers at transaction entry rather than row locks") + service, _, _, _ = await _seed(factory, tmp_path) + statements = [] + + def track(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + await service.fetch_issue(session, 91) + parent_lock = next( + i for i, sql in enumerate(statements) if "FROM series " in sql and "FOR UPDATE" in sql + ) + child_lock = next( + i for i, sql in enumerate(statements) if "FROM issues " in sql and "FOR UPDATE" in sql + ) + child_write = next( + i for i, sql in enumerate(statements) if sql.startswith("UPDATE issues ") + ) + assert parent_lock < child_lock < child_write + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) From 870aae276c07572febfd9250c5965ab34b2fc7bc Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 22:46:53 -0700 Subject: [PATCH 13/97] feat: preserve story arc identity lifecycle and migration history Add canonical provider ownership constraints while retaining Mylar and folder-scoped references. Backfill verified legacy evidence with guarded replay and downgrade, and keep legacy audit fixtures representative of the predecessor schema. --- ...l0m1n2o345_story_arc_identity_lifecycle.py | 339 ++++++++++++++++++ src/pullbox/models/story_arc.py | 31 ++ .../fixtures/metadata_identity_persistence.py | 11 + .../test_arc_identity_migration.py | 291 +++++++++++++++ .../test_legacy_audit_contract.py | 4 +- tests/integration/test_migrations.py | 3 +- tests/unit/test_metadata_identity_audit.py | 4 + .../unit/test_metadata_identity_inventory.py | 2 + 8 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py create mode 100644 tests/integration/metadata_identity/test_arc_identity_migration.py diff --git a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py new file mode 100644 index 00000000..87b54895 --- /dev/null +++ b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py @@ -0,0 +1,339 @@ +"""Add canonical Story Arc identity lifecycle without changing import scopes.""" + +import hashlib +import json + +import sqlalchemy as sa + +from alembic import op + +revision = "r9l0m1n2o345" +down_revision = "q8k9l0m1n234" +branch_labels = None +depends_on = None + +_NAME = "story_arc_external_identities" +_PAGE = 200 +_OPERATION = "593a9848-6071-40d7-9cd5-fffece1dc7f4" +_PROVIDERS = ("comicvine", "metron", "gcd", "locg") +_STATES = ("observed", "verified", "conflicted", "stale", "rejected") +_EVIDENCE = ( + "legacy_backfill", + "mylar_database", + "series_json", + "comicinfo_xml", + "metroninfo_xml", + "provider_result", + "provider_crosswalk", + "user_selection", + "migration", +) +_FIELDS = ( + "verification_state", + "evidence_kind", + "evidence_locator", + "verified_at", + "last_seen_at", + "revision", +) +_CHECKS = { + "identityverificationstate": "verification_state IN " + str(_STATES), + "identityevidencekind": "evidence_kind IN " + str(_EVIDENCE), + "ck_story_arc_identity_active_state": "verification_state IN ('verified','stale','conflicted')", + "ck_story_arc_identity_revision": "revision > 0", +} + + +def _json(value: dict) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _tables() -> tuple[sa.Table, sa.Table, sa.Table]: + metadata = sa.MetaData() + return tuple( + sa.Table(name, metadata, autoload_with=op.get_bind()) + for name in ( + "story_arcs", + _NAME, + "story_arc_identity_events", + ) + ) + + +def _canonical(active: sa.Table): + return sa.and_(active.c.namespace == "story_arc", active.c.source.in_(_PROVIDERS)) + + +def _pages(table: sa.Table, condition=None): + cursor = 0 + while ( + rows := op.get_bind() + .execute( + sa.select(table) + .where(table.c.id > cursor, condition if condition is not None else sa.true()) + .order_by(table.c.id) + .limit(_PAGE) + ) + .mappings() + .all() + ): + cursor = rows[-1].id + yield rows + + +def _fail() -> None: + raise RuntimeError( + "Story Arc identity evidence needs review before migration or downgrade; " + "no ownership may be discarded." + ) + + +def _valid(value: str) -> bool: + return bool( + value and len(value) <= 255 and value.isascii() and value.isdecimal() and value[0] != "0" + ) + + +def _preflight(arcs: sa.Table, active: sa.Table) -> None: + conn = op.get_bind() + if conn.execute( + sa.select(active.c.story_arc_id) + .where(_canonical(active)) + .group_by(active.c.story_arc_id, active.c.source) + .having(sa.func.count() > 1) + .limit(1) + ).first(): + _fail() + for rows in _pages(active, _canonical(active)): + for row in rows: + if not _valid(row.external_id) or ( + row.source == "comicvine" and int(row.external_id) >= 2**63 + ): + _fail() + parents = dict( + conn.execute( + sa.select(arcs.c.id, arcs.c.comicvine_id).where( + arcs.c.id.in_([row.story_arc_id for row in rows]) + ) + ).all() + ) + cv_rows = [row for row in rows if row.source == "comicvine"] + owners = { + value: local_id + for local_id, value in conn.execute( + sa.select(arcs.c.id, arcs.c.comicvine_id).where( + sa.cast(arcs.c.comicvine_id, sa.BigInteger).in_( + [int(row.external_id) for row in cv_rows] + ) + ) + ) + } + for row in rows: + if row.story_arc_id not in parents: + _fail() + if row.source == "comicvine" and ( + parents[row.story_arc_id] not in (None, int(row.external_id)) + or owners.get(int(row.external_id), row.story_arc_id) != row.story_arc_id + ): + _fail() + + +def _expected(local_id: int, namespace: str, external_id: str) -> tuple[dict, dict]: + # Frozen v1 event envelope; do not import mutable runtime event policy. + origin = {"record_kind": "story_arc", "record_id": local_id} + evidence_revision = _digest(f"legacy_arc:{namespace}:{local_id}:{external_id}") + slot = { + "version": 1, + "operation_id": _OPERATION, + "entity_kind": "story_arc", + "local_id": local_id, + "identity_namespace": namespace, + "action": "verify", + "origin": origin, + "evidence_revision": evidence_revision, + } + payload = { + "version": 1, + "operation_id": _OPERATION, + "local_id": local_id, + "identity": { + "namespace": namespace, + "entity_kind": "story_arc", + "external_id": external_id, + }, + "action": "verify", + "evidence_kind": "legacy_backfill", + "evidence_revision": evidence_revision, + "origin": origin, + "actor": "migration", + "actor_user_id": None, + "review_revision": None, + } + serialized = _json(payload) + owner = { + "verification_state": "verified", + "evidence_kind": "legacy_backfill", + "evidence_locator": _json(origin), + "verified_at": None, + "last_seen_at": None, + "revision": 1, + } + event = { + "story_arc_id": local_id, + "identity_namespace": namespace, + "external_id": external_id, + "verification_state": "verified", + "evidence_kind": "legacy_backfill", + "event_key": _digest(_json(slot)), + "request_fingerprint": _digest(serialized), + "request_json": serialized, + } + return owner, event + + +def _equal(row, expected: dict) -> bool: + return all(row[key] == value for key, value in expected.items()) + + +def _validate_events(active: sa.Table, events: sa.Table) -> None: + for rows in _pages(events): + keys = [(row.story_arc_id, row.identity_namespace, row.external_id) for row in rows] + owners = { + tuple(row): True + for row in op.get_bind().execute( + sa.select(active.c.story_arc_id, active.c.source, active.c.external_id).where( + _canonical(active), + sa.tuple_(active.c.story_arc_id, active.c.source, active.c.external_id).in_( + keys + ), + ) + ) + } + for row in rows: + if ( + row.story_arc_id, + row.identity_namespace, + row.external_id, + ) not in owners or not _equal( + row, _expected(row.story_arc_id, row.identity_namespace, row.external_id)[1] + ): + _fail() + + +def upgrade() -> None: + conn = op.get_bind() + arcs, active, events = _tables() + _preflight(arcs, active) + # Refuse prior decisions before SQLite performs non-transactional DDL. + _validate_events(active, events) + existing = {col["name"] for col in sa.inspect(conn).get_columns(_NAME)} + columns = ( + sa.Column("verification_state", sa.String(max(map(len, _STATES)))), + sa.Column("evidence_kind", sa.String(max(map(len, _EVIDENCE)))), + sa.Column("evidence_locator", sa.Text), + sa.Column("verified_at", sa.DateTime(timezone=True)), + sa.Column("last_seen_at", sa.DateTime(timezone=True)), + sa.Column("revision", sa.Integer, nullable=False, server_default="1"), + ) + for column in columns: + if column.name not in existing: + op.add_column(_NAME, column) + arcs, active, events = _tables() + # Preserve column-only ComicVine arcs as canonical relations too. + for rows in _pages(arcs, arcs.c.comicvine_id > 0): + present = set( + conn.execute( + sa.select(active.c.story_arc_id).where( + active.c.story_arc_id.in_([row.id for row in rows]), + active.c.source == "comicvine", + active.c.namespace == "story_arc", + ) + ).scalars() + ) + values = [ + { + "story_arc_id": row.id, + "source": "comicvine", + "namespace": "story_arc", + "external_id": str(row.comicvine_id), + "source_url": row.comicvine_url, + "evidence": {}, + } + for row in rows + if row.id not in present + ] + if values: + conn.execute(sa.insert(active), values) + for rows in _pages(active, _canonical(active)): + expected = [ + (row, *_expected(row.story_arc_id, row.source, row.external_id)) for row in rows + ] + prior = { + row.event_key: row + for row in conn.execute( + sa.select(events).where( + events.c.event_key.in_([event["event_key"] for _, _, event in expected]) + ) + ).mappings() + } + creates = [] + for row, owner, event in expected: + untouched = ( + all(row[field] is None for field in _FIELDS if field != "revision") + and row.revision == 1 + ) + if not untouched and not _equal(row, owner): + _fail() + if untouched: + conn.execute(sa.update(active).where(active.c.id == row.id).values(**owner)) + if event["event_key"] in prior: + if not _equal(prior[event["event_key"]], event): + _fail() + else: + creates.append(event) + if creates: + conn.execute(sa.insert(events), creates) + checks = {row["name"] for row in sa.inspect(conn).get_check_constraints(_NAME)} + if missing := _CHECKS.keys() - checks: + with op.batch_alter_table(_NAME) as batch: + for name in sorted(missing): + batch.create_check_constraint(name, _CHECKS[name]) + indexes = {row["name"] for row in sa.inspect(conn).get_indexes(_NAME)} + if "uq_story_arc_canonical_provider" not in indexes: + predicate = sa.text( + "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')" + ) + op.create_index( + "uq_story_arc_canonical_provider", + _NAME, + ["story_arc_id", "source"], + unique=True, + sqlite_where=predicate, + postgresql_where=predicate, + ) + + +def downgrade() -> None: + arcs, active, events = _tables() + _preflight(arcs, active) + _validate_events(active, events) + for rows in _pages(active): + for row in rows: + expected = ( + _expected(row.story_arc_id, row.source, row.external_id)[0] + if (row.namespace == "story_arc" and row.source in _PROVIDERS) + else {field: 1 if field == "revision" else None for field in _FIELDS} + ) + if not _equal(row, expected): + _fail() + op.get_bind().execute(sa.delete(events)) + op.drop_index("uq_story_arc_canonical_provider", table_name=_NAME) + with op.batch_alter_table(_NAME) as batch: + for name in _CHECKS: + batch.drop_constraint(name, type_="check") + for field in reversed(_FIELDS): + batch.drop_column(field) diff --git a/src/pullbox/models/story_arc.py b/src/pullbox/models/story_arc.py index 3363fd14..39199423 100644 --- a/src/pullbox/models/story_arc.py +++ b/src/pullbox/models/story_arc.py @@ -17,10 +17,13 @@ Text, UniqueConstraint, event, + text, ) from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.orm import Mapped, mapped_column, relationship, validates +from pullbox.core.metadata_identity import IdentityEvidenceKind +from pullbox.core.metadata_identity_state import IdentityVerificationState from pullbox.core.story_arc_identity import normalize_story_arc_name from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime @@ -311,6 +314,23 @@ class StoryArcExternalIdentity(Base, IdentityMixin, TimestampMixin): name="uq_story_arc_external_identity", ), Index("ix_story_arc_external_identities_arc_id", "story_arc_id", "id"), + Index( + "uq_story_arc_canonical_provider", + "story_arc_id", + "source", + unique=True, + sqlite_where=text( + "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')" + ), + postgresql_where=text( + "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')" + ), + ), + CheckConstraint( + "verification_state IN ('verified','stale','conflicted')", + name="ck_story_arc_identity_active_state", + ), + CheckConstraint("revision > 0", name="ck_story_arc_identity_revision"), ) story_arc_id: Mapped[int] = mapped_column( @@ -323,6 +343,17 @@ class StoryArcExternalIdentity(Base, IdentityMixin, TimestampMixin): evidence: Mapped[dict] = mapped_column( # type: ignore[type-arg] JSON, default=dict, server_default="{}", nullable=False ) + # Scoped import references are not provider ownership and keep null proof. + verification_state: Mapped[IdentityVerificationState | None] = mapped_column( + story_arc_enum(IdentityVerificationState) + ) + evidence_kind: Mapped[IdentityEvidenceKind | None] = mapped_column( + story_arc_enum(IdentityEvidenceKind) + ) + evidence_locator: Mapped[str | None] = mapped_column(Text) + verified_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + revision: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False) story_arc: Mapped[StoryArc] = relationship(back_populates="external_identities") diff --git a/tests/fixtures/metadata_identity_persistence.py b/tests/fixtures/metadata_identity_persistence.py index e3f7a0ef..3168eb73 100644 --- a/tests/fixtures/metadata_identity_persistence.py +++ b/tests/fixtures/metadata_identity_persistence.py @@ -29,6 +29,7 @@ from pullbox.models.story_arc import StoryArcExternalIdentity if TYPE_CHECKING: + from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker type IdentityProbeDatabase = tuple[ @@ -36,6 +37,16 @@ ] +def drop_canonical_arc_index(connection: "Connection") -> None: + """Recreate pre-migration ownership rules only in disposable legacy fixtures.""" + index = next( + index + for index in StoryArcExternalIdentity.__table__.indexes + if index.name == "uq_story_arc_canonical_provider" + ) + index.drop(connection) + + @dataclass(frozen=True) class IdentitySchemaProbe: series: Table diff --git a/tests/integration/metadata_identity/test_arc_identity_migration.py b/tests/integration/metadata_identity/test_arc_identity_migration.py new file mode 100644 index 00000000..2e53d024 --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_identity_migration.py @@ -0,0 +1,291 @@ +"""Real arc lifecycle migration preserves scoped import keys and retained history.""" + +from __future__ import annotations + +import json + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from sqlalchemy import ( + JSON, + Column, + DateTime, + ForeignKey, + Index, + Integer, + MetaData, + String, + Table, + UniqueConstraint, + func, + insert, + inspect, + select, + update, +) +from sqlalchemy.exc import IntegrityError + +from pullbox.models import Base, StoryArc +from tests.integration.metadata_identity.test_production_migration import _revision + +_NAME = "story_arc_external_identities" +_NEW = { + "verification_state", + "evidence_kind", + "evidence_locator", + "verified_at", + "last_seen_at", + "revision", +} + + +def _migration(conn): + return _revision("r9l0m1n2o345_story_arc_identity_lifecycle", conn) + + +async def _old_schema(engine): + metadata = MetaData() + Table("story_arcs", metadata, Column("id", Integer, primary_key=True)) + table = Table( + _NAME, + metadata, + Column("id", Integer, primary_key=True), + Column( + "story_arc_id", Integer, ForeignKey("story_arcs.id", ondelete="CASCADE"), nullable=False + ), + Column("source", String(50), nullable=False), + Column("namespace", String(100), nullable=False), + Column("external_id", String(255), nullable=False), + Column("source_url", String(1000)), + Column("evidence", JSON, nullable=False, server_default="{}"), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + UniqueConstraint( + "source", "namespace", "external_id", name="uq_story_arc_external_identity" + ), + Index("ix_story_arc_external_identities_arc_id", "story_arc_id", "id"), + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.tables[_NAME].drop) + await connection.run_sync(table.create) + return table + + +async def _arcs(factory, values=(31, None)): + async with factory.begin() as session: + arcs = [StoryArc(name=f"Arc {i}", comicvine_id=value) for i, value in enumerate(values)] + session.add_all(arcs) + await session.flush() + return [arc.id for arc in arcs] + + +async def test_arc_migration_backfills_proof_and_preserves_import_scopes(identity_probe_db): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, second = await _arcs(factory) + async with engine.begin() as connection: + await connection.execute( + insert(old), + [ + { + "story_arc_id": first, + "source": "mylar3", + "namespace": "db-a", + "external_id": "non-numeric", + }, + { + "story_arc_id": first, + "source": "mylar3", + "namespace": "db-b", + "external_id": "non-numeric", + }, + { + "story_arc_id": first, + "source": "comicvine", + "namespace": "import-scope", + "external_id": "arbitrary", + }, + { + "story_arc_id": second, + "source": "gcd", + "namespace": "story_arc", + "external_id": "71", + }, + ], + ) + before = (await connection.execute(select(old).order_by(old.c.id))).all() + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + columns = await connection.run_sync( + lambda conn: {c["name"] for c in inspect(conn).get_columns(_NAME)} + ) + assert columns >= _NEW + active = Base.metadata.tables[_NAME] + events = Base.metadata.tables["story_arc_identity_events"] + assert ( + await connection.execute( + select(old).where(old.c.id <= before[-1].id).order_by(old.c.id) + ) + ).all() == before + rows = (await connection.execute(select(active).order_by(active.c.id))).mappings().all() + assert len(rows) == 5 + assert [row.verification_state for row in rows] == [ + None, + None, + None, + "verified", + "verified", + ] + assert rows[-1].external_id == "31" and rows[-1].story_arc_id == first + assert all(row.verified_at is None and row.last_seen_at is None for row in rows) + history = (await connection.execute(select(events))).mappings().all() + assert len(history) == 2 + for event in history: + payload = json.loads(event.request_json) + assert ( + payload["actor"] == "migration" + and payload["identity"]["entity_kind"] == "story_arc" + ) + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert await connection.scalar(select(func.count()).select_from(events)) == 2 + await connection.run_sync(lambda conn: _migration(conn).downgrade()) + assert await connection.scalar(select(func.count()).select_from(events)) == 0 + assert len((await connection.execute(select(old))).all()) == 5 + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert await connection.scalar(select(func.count()).select_from(events)) == 2 + + +@pytest.mark.parametrize( + "problem", ["duplicate_provider", "column_disagreement", "other_owner", "padded", "invalid"] +) +async def test_arc_migration_refuses_ambiguous_legacy_evidence_before_ddl( + identity_probe_db, problem +): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, second = await _arcs(factory) + rows = [ + { + "story_arc_id": first, + "source": "comicvine", + "namespace": "story_arc", + "external_id": "31", + } + ] + if problem == "duplicate_provider": + rows.append({**rows[0], "external_id": "32"}) + elif problem == "column_disagreement": + rows[0]["external_id"] = "32" + elif problem == "other_owner": + rows[0]["story_arc_id"] = second + else: + rows[0]["external_id"] = "0031" if problem == "padded" else "unparseable" + async with engine.begin() as connection: + await connection.execute(insert(old), rows) + before = (await connection.execute(select(old))).all() + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert (await connection.execute(select(old))).all() == before + columns = await connection.run_sync( + lambda conn: {c["name"] for c in inspect(conn).get_columns(_NAME)} + ) + assert not _NEW & columns + + +@pytest.mark.parametrize("change", ["state", "history", "revision", "scoped_lifecycle"]) +async def test_arc_downgrade_refuses_to_discard_later_decisions(identity_probe_db, change): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, _ = await _arcs(factory) + async with engine.begin() as connection: + await connection.execute( + insert(old).values(story_arc_id=first, source="folder", namespace="a", external_id="a") + ) + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + columns = await connection.run_sync( + lambda conn: {c["name"] for c in inspect(conn).get_columns(_NAME)} + ) + assert columns >= _NEW + active = Base.metadata.tables[_NAME] + events = Base.metadata.tables["story_arc_identity_events"] + if change == "history": + await connection.execute(update(events).values(verification_state="rejected")) + elif change == "scoped_lifecycle": + await connection.execute( + update(active).where(active.c.source == "folder").values(evidence_kind="migration") + ) + else: + await connection.execute( + update(active) + .where(active.c.source == "comicvine") + .values( + **({"verification_state": "stale"} if change == "state" else {"revision": 2}) + ) + ) + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).downgrade()) + assert await connection.scalar(select(func.count()).select_from(events)) == 1 + + +async def test_arc_schema_matches_models_and_only_constrains_provider_scope(identity_probe_db): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, _ = await _arcs(factory, (None, None)) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + + def compare(conn): + expected = MetaData() + Table("story_arcs", expected, Column("id", Integer, primary_key=True)) + Base.metadata.tables[_NAME].to_metadata(expected) + + def compare_defaults(context, inspected, modeled, rendered, default, modeled_text): + # PostgreSQL JSON has no equality operator for Alembic's default comparison. + if context.dialect.name == "postgresql" and isinstance(modeled.type, JSON): + actual = rendered.removesuffix("::json").removeprefix("'").removesuffix("'") + return json.loads(actual) != json.loads(default.arg) + return None + + return compare_metadata( + MigrationContext.configure( + conn, + opts={ + "include_object": lambda obj, name, type_, reflected, compare_to: ( + name == _NAME if type_ == "table" else True + ), + "compare_server_default": compare_defaults, + }, + ), + expected, + ) + + assert await connection.run_sync(compare) == [] + await connection.execute( + insert(old).values( + story_arc_id=first, source="gcd", namespace="story_arc", external_id="70" + ) + ) + with pytest.raises(IntegrityError): + async with connection.begin_nested(): + await connection.execute( + insert(old).values( + story_arc_id=first, source="gcd", namespace="story_arc", external_id="71" + ) + ) + await connection.execute( + insert(old), + [ + { + "story_arc_id": first, + "source": "gcd", + "namespace": "import-a", + "external_id": "71", + }, + { + "story_arc_id": first, + "source": "gcd", + "namespace": "import-b", + "external_id": "72", + }, + ], + ) diff --git a/tests/integration/metadata_identity/test_legacy_audit_contract.py b/tests/integration/metadata_identity/test_legacy_audit_contract.py index 7a5b8667..e9c8ed6a 100644 --- a/tests/integration/metadata_identity/test_legacy_audit_contract.py +++ b/tests/integration/metadata_identity/test_legacy_audit_contract.py @@ -14,6 +14,7 @@ IdentityAuditStopReason, audit_legacy_identities, ) +from tests.fixtures.metadata_identity_persistence import drop_canonical_arc_index if TYPE_CHECKING: from tests.fixtures.metadata_identity_persistence import IdentityProbeDatabase @@ -68,10 +69,11 @@ async def test_duplicate_normalized_legacy_rows_are_reported_not_deleted( identity_probe_db: IdentityProbeDatabase, ) -> None: engine, factory, probe = identity_probe_db - # Reproduce pre-migration rows: the proposed partial index is not in the app schema. + # Reproduce pre-migration rows without either canonical-ownership index. assert probe.arc_index is not None async with engine.begin() as connection: await connection.run_sync(probe.arc_index.drop) + await connection.run_sync(drop_canonical_arc_index) async with factory.begin() as session: arc = StoryArc(name="Duplicate legacy rows", comicvine_id=42) session.add(arc) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 250f5caa..d0ad4c7b 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["q8k9l0m1n234"] + assert script.get_heads() == ["r9l0m1n2o345"] + assert script.get_revision("r9l0m1n2o345").down_revision == "q8k9l0m1n234" assert script.get_revision("q8k9l0m1n234").down_revision == "p7j8k9l0m123" assert script.get_revision("p7j8k9l0m123").down_revision == "o6i7j8k9l012" assert script.get_revision("o6i7j8k9l012").down_revision == "n5h6i7j8k901" diff --git a/tests/unit/test_metadata_identity_audit.py b/tests/unit/test_metadata_identity_audit.py index 7f495f57..c217feb2 100644 --- a/tests/unit/test_metadata_identity_audit.py +++ b/tests/unit/test_metadata_identity_audit.py @@ -19,6 +19,7 @@ audit_legacy_identities, ) from pullbox.services.metadata_identity_inventory import LegacyIdentityStorage +from tests.fixtures.metadata_identity_persistence import drop_canonical_arc_index if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -95,6 +96,7 @@ async def test_agreement_is_not_collision_and_provider_namespaces_stay_independe async def test_equivalent_duplicate_relations_need_explicit_consolidation_not_an_owner_winner( db_session: AsyncSession, ) -> None: + await (await db_session.connection()).run_sync(drop_canonical_arc_index) [arc] = await _arcs(db_session, [42]) relations = [_relation(arc, "42"), _relation(arc, "00042")] db_session.add_all(relations) @@ -194,6 +196,7 @@ async def test_evidence_budget_keeps_prior_findings_but_does_not_count_partial_p async def test_invalid_evidence_uses_the_same_retained_memory_budget( db_session: AsyncSession, ) -> None: + await (await db_session.connection()).run_sync(drop_canonical_arc_index) arcs = await _arcs(db_session, [0, None, None]) db_session.add_all([_relation(arcs[1], "bad-1"), _relation(arcs[1], "bad-2")]) await db_session.commit() @@ -210,6 +213,7 @@ async def test_invalid_evidence_uses_the_same_retained_memory_budget( async def test_relation_overflow_is_explicit_incomplete_and_keeps_prior_findings( db_session: AsyncSession, ) -> None: + await (await db_session.connection()).run_sync(drop_canonical_arc_index) arcs = await _arcs(db_session, [0, None]) db_session.add_all([_relation(arcs[1], str(index + 1)) for index in range(2001)]) await db_session.commit() diff --git a/tests/unit/test_metadata_identity_inventory.py b/tests/unit/test_metadata_identity_inventory.py index 20738be5..d4b5b566 100644 --- a/tests/unit/test_metadata_identity_inventory.py +++ b/tests/unit/test_metadata_identity_inventory.py @@ -16,6 +16,7 @@ LegacyIdentityStorage, read_legacy_identity_page, ) +from tests.fixtures.metadata_identity_persistence import drop_canonical_arc_index if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -242,6 +243,7 @@ def record( async def test_arc_inventory_refuses_silent_relation_truncation(db_session: AsyncSession) -> None: + await (await db_session.connection()).run_sync(drop_canonical_arc_index) [arc] = await _targets(db_session, MetadataEntityKind.STORY_ARC, [None]) db_session.add_all( [ From c4a437cd311132917548848ca8f388a2a82cf628 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Sun, 27 Sep 2026 23:26:08 -0700 Subject: [PATCH 14/97] feat: route story arc catalog identities through shared history Record canonical arc evidence atomically with catalog adoption and refresh. Preserve scoped import references and legacy source metadata, validate current ownership on replay, and lock mixed arc/series/issue graphs consistently. --- .../services/metadata_identity_attachment.py | 76 ++- src/pullbox/services/story_arc_catalog.py | 16 +- .../services/story_arc_catalog_persistence.py | 40 ++ .../test_arc_identity_attachment.py | 444 ++++++++++++++++++ 4 files changed, 548 insertions(+), 28 deletions(-) create mode 100644 tests/integration/metadata_identity/test_arc_identity_attachment.py diff --git a/src/pullbox/services/metadata_identity_attachment.py b/src/pullbox/services/metadata_identity_attachment.py index 0603f05d..132125b1 100644 --- a/src/pullbox/services/metadata_identity_attachment.py +++ b/src/pullbox/services/metadata_identity_attachment.py @@ -7,7 +7,7 @@ from itertools import groupby from typing import TYPE_CHECKING -from sqlalchemy import false, func, insert, or_, select, tuple_, update +from sqlalchemy import false, func, insert, or_, select, true, tuple_, update from sqlalchemy.exc import IntegrityError from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind @@ -22,7 +22,7 @@ IdentityVerificationAction, IdentityVerificationState, ) -from pullbox.models import Base, Issue, Series +from pullbox.models import Base, Issue, Series, StoryArc, StoryArcExternalIdentity from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity if TYPE_CHECKING: @@ -34,6 +34,24 @@ from pullbox.core.metadata_identity_events import IdentityEventRequest _BATCH_SIZE = 200 +_TARGET_MODELS: dict[MetadataEntityKind, type[Series] | type[Issue] | type[StoryArc]] = { + MetadataEntityKind.SERIES: Series, + MetadataEntityKind.ISSUE: Issue, + MetadataEntityKind.STORY_ARC: StoryArc, +} +_OWNER_MODELS: dict[ + MetadataEntityKind, + type[SeriesExternalIdentity] | type[IssueExternalIdentity] | type[StoryArcExternalIdentity], +] = { + MetadataEntityKind.SERIES: SeriesExternalIdentity, + MetadataEntityKind.ISSUE: IssueExternalIdentity, + MetadataEntityKind.STORY_ARC: StoryArcExternalIdentity, +} +_LOCK_ORDER = { + MetadataEntityKind.STORY_ARC: 0, + MetadataEntityKind.SERIES: 1, + MetadataEntityKind.ISSUE: 2, +} class IdentityAttachmentConflictError(ValueError): @@ -68,7 +86,7 @@ async def attach_verified_identities( if ( request.action is not IdentityVerificationAction.VERIFY or request.actor is not IdentityEventActor.AUTOMATION - or identity.entity_kind not in {MetadataEntityKind.SERIES, MetadataEntityKind.ISSUE} + or identity.entity_kind not in _TARGET_MODELS ): raise ValueError("This attachment path accepts validated automatic verification only") if ( @@ -123,7 +141,7 @@ async def attach_verified_identities( def _sort_key(request: IdentityEventRequest) -> tuple[int, int, str, str]: identity = request.evidence.claim.identity return ( - 0 if identity.entity_kind is MetadataEntityKind.SERIES else 1, + _LOCK_ORDER[identity.entity_kind], request.local_id, identity.namespace.value, identity.external_id, @@ -132,6 +150,21 @@ def _sort_key(request: IdentityEventRequest) -> tuple[int, int, str, str]: async def _lock_parent_graph(session: AsyncSession, requests: list[IdentityEventRequest]) -> None: """Acquire every parent before any child, across the whole batch sequence.""" + # Catalog refresh owns its arc before touching member series/issues. + arc_ids = sorted( + { + request.local_id + for request in requests + if request.evidence.claim.identity.entity_kind is MetadataEntityKind.STORY_ARC + } + ) + for offset in range(0, len(arc_ids), _BATCH_SIZE): + await session.execute( + select(StoryArc.id) + .where(StoryArc.id.in_(arc_ids[offset : offset + _BATCH_SIZE])) + .order_by(StoryArc.id) + .with_for_update() + ) series_ids = { request.local_id for request in requests @@ -180,7 +213,7 @@ async def _lock_parent_graph(session: AsyncSession, requests: list[IdentityEvent async def _locked_targets( session: AsyncSession, kind: MetadataEntityKind, target_ids: list[int] ) -> dict[int, RowMapping]: - model = Series if kind is MetadataEntityKind.SERIES else Issue + model = _TARGET_MODELS[kind] table = Base.metadata.tables[model.__tablename__] columns = [table.c.id, table.c.comicvine_id] if kind is MetadataEntityKind.ISSUE: @@ -210,6 +243,16 @@ async def _attach_batch( target_key = f"{kind.value}_id" active = Base.metadata.tables[f"{kind.value}_external_identities"] history = Base.metadata.tables[f"{kind.value}_identity_events"] + is_arc = kind is MetadataEntityKind.STORY_ARC + namespace_column = active.c.source if is_arc else active.c.identity_namespace + active_scope = ( + (active.c.namespace == "story_arc") & active.c.source.in_(list(IdentityNamespace)) + if is_arc + else true() + ) + active_select = ( + select(active, namespace_column.label("identity_namespace")) if is_arc else select(active) + ) targets = await _locked_targets( session, kind, sorted({request.local_id for request in requests}) ) @@ -255,10 +298,11 @@ async def _attach_batch( active_rows = ( ( await session.execute( - select(active).where( + active_select.where( + active_scope, or_( active.c[target_key].in_([request.local_id for request in checked]), - tuple_(active.c.identity_namespace, active.c.external_id).in_( + tuple_(namespace_column, active.c.external_id).in_( [ ( request.evidence.claim.identity.namespace, @@ -267,7 +311,7 @@ async def _attach_batch( for request in checked ] ), - ) + ), ) ) ) @@ -374,7 +418,11 @@ async def _attach_batch( creates[target] = { **values, target_key: request.local_id, - "identity_namespace": namespace, + **( + {"source": namespace.value, "namespace": "story_arc"} + if is_arc + else {"identity_namespace": namespace} + ), "external_id": external_id, } else: @@ -403,16 +451,12 @@ async def _attach_batch( await session.execute(insert(active), list(creates.values())) if changes: await session.execute( - update( - SeriesExternalIdentity - if kind is MetadataEntityKind.SERIES - else IssueExternalIdentity - ), + update(_OWNER_MODELS[kind]), list(changes.values()), ) if legacy_changes: await session.execute( - update(Series if kind is MetadataEntityKind.SERIES else Issue), + update(_TARGET_MODELS[kind]), list(legacy_changes.values()), ) rows = await session.execute( @@ -431,7 +475,7 @@ async def _validate_legacy_owners( requests: list[IdentityEventRequest], targets: dict[int, RowMapping], ) -> None: - model = Series if kind is MetadataEntityKind.SERIES else Issue + model = _TARGET_MODELS[kind] legacy_claims = [ (request.local_id, int(request.evidence.claim.identity.external_id)) for request in requests diff --git a/src/pullbox/services/story_arc_catalog.py b/src/pullbox/services/story_arc_catalog.py index e749cd7a..634a0660 100644 --- a/src/pullbox/services/story_arc_catalog.py +++ b/src/pullbox/services/story_arc_catalog.py @@ -21,6 +21,7 @@ StoryArcSourceKind, ) from pullbox.services.story_arc_catalog_persistence import ( + attach_arc_identity, canonical_root, publisher_id, seed_members, @@ -203,7 +204,6 @@ async def add( raise StoryArcCatalogError( "identity_conflict", "This provider story arc identity is already assigned" ) - issues = await seed_members(session, preview, root, order) arc = await self.domain.create( session, name=preview.metadata.title, @@ -214,23 +214,14 @@ async def add( sync_enabled=policy.synchronize, source_kind=StoryArcSourceKind.PROVIDER, ) - arc.comicvine_id = exact_provider_id(preview.metadata.provider_id) arc.comicvine_url = preview.metadata.comicvine_url arc.cover_url = preview.metadata.cover_url arc.publisher_id = await publisher_id(session, preview.metadata.publisher) arc.target_library_root_id = policy.target_library_root_id arc.policy_schema_version = STORY_ARC_PLACEMENT_POLICY_SCHEMA_VERSION arc.policy_snapshot = policy.snapshot - session.add( - StoryArcExternalIdentity( - story_arc_id=arc.id, - source="comicvine", - namespace="story_arc", - external_id=preview.metadata.provider_id, - source_url=preview.metadata.comicvine_url, - evidence={"snapshot_fingerprint": preview.fingerprint}, - ) - ) + await attach_arc_identity(session, arc, preview) + issues = await seed_members(session, preview, root, order) for position, provider_id in enumerate(order, start=1): member = await self._member( session, arc, preview, issues[provider_id], provider_id, position @@ -333,6 +324,7 @@ async def refresh( raise StoryArcCatalogError( "revision_conflict", "Story arc changed; refresh the review" ) + await attach_arc_identity(session, arc, preview) issues = await seed_members( session, preview, root, preview.metadata.issue_provider_ids ) diff --git a/src/pullbox/services/story_arc_catalog_persistence.py b/src/pullbox/services/story_arc_catalog_persistence.py index a0360d0b..6f171ce9 100644 --- a/src/pullbox/services/story_arc_catalog_persistence.py +++ b/src/pullbox/services/story_arc_catalog_persistence.py @@ -32,6 +32,7 @@ from pullbox.models.library import LibraryRoot from pullbox.models.publisher import Publisher from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity from pullbox.services.library_root_management import validate_managed_library_root from pullbox.services.metadata_identity_attachment import ( IdentityAttachmentConflictError, @@ -204,6 +205,45 @@ async def _attach_member_identities( ) from exc +async def attach_arc_identity( + session: AsyncSession, arc: StoryArc, preview: StoryArcCatalogPreview +) -> None: + """Record the validated preview without treating an old receipt as current proof.""" + identity = ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.STORY_ARC, preview.metadata.provider_id + ) + request = IdentityEventRequest( + uuid5(UUID("e5975ab0-13f6-4e1f-b7af-c3a00ccab88b"), preview.fingerprint), + arc.id, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.COMICVINE_API + ), + preview.fingerprint, + source_identity=identity, + ), + ) + try: + await attach_verified_identities(session, [request], require_current_ownership=True) + except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc: + raise StoryArcCatalogError( + "identity_conflict", "Story arc identity needs review before updating its catalog" + ) from exc + owner = await session.scalar( + select(StoryArcExternalIdentity).where( + StoryArcExternalIdentity.story_arc_id == arc.id, + StoryArcExternalIdentity.source == "comicvine", + StoryArcExternalIdentity.namespace == "story_arc", + ) + ) + assert owner is not None + if preview.metadata.comicvine_url is not None: + owner.source_url = preview.metadata.comicvine_url + owner.evidence = {**owner.evidence, "snapshot_fingerprint": preview.fingerprint} + await session.refresh(arc, ["comicvine_id"]) + + async def _new_series(session: AsyncSession, metadata: SeriesMetadata, root: LibraryRoot) -> Series: series = Series( comicvine_id=exact_provider_id(metadata.provider_id), diff --git a/tests/integration/metadata_identity/test_arc_identity_attachment.py b/tests/integration/metadata_identity/test_arc_identity_attachment.py new file mode 100644 index 00000000..20ddeecc --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_identity_attachment.py @@ -0,0 +1,444 @@ +"""Canonical arc attachment shares ownership/history without claiming import scopes.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, event, func, select, update + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventEvidence, + IdentityEventRequest, + prepare_identity_event, +) +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, +) +from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesIdentityEvent, + StoryArcIdentityEvent, +) +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + attach_verified_identities, +) +from pullbox.services.story_arc_catalog import StoryArcCatalogService +from pullbox.services.story_arc_catalog_types import StoryArcCatalogError +from tests.integration.metadata_identity.test_attachment_service import _request +from tests.unit.test_story_arc_catalog import _provider, _root + + +def _arc_request(target, external="31", namespace=IdentityNamespace.COMICVINE): + identity = ExternalIdentityRef(namespace, MetadataEntityKind.STORY_ARC, external) + if namespace is IdentityNamespace.LOCG: + from pullbox.core.metadata_identity_events import ( + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + ) + + evidence = IdentityEventEvidence( + ExactIdentityEvidence(identity, IdentityEvidenceKind.MIGRATION), + "a" * 64, + locator=IdentityEvidenceLocator(IdentityEvidenceRecordKind.STORY_ARC, target), + ) + else: + source = { + IdentityNamespace.COMICVINE: MetadataSource.COMICVINE_API, + IdentityNamespace.METRON: MetadataSource.METRON_API, + IdentityNamespace.GCD: MetadataSource.GCD_API_V2, + }[namespace] + evidence = IdentityEventEvidence( + ExactIdentityEvidence(identity, IdentityEvidenceKind.PROVIDER_RESULT, source), + "a" * 64, + source_identity=identity, + ) + return IdentityEventRequest(uuid4(), target, IdentityVerificationAction.VERIFY, evidence) + + +async def _arcs(factory, count=2): + async with factory.begin() as session: + rows = [StoryArc(name=f"Arc {i}") for i in range(count)] + session.add_all(rows) + await session.flush() + return [row.id for row in rows] + + +@pytest.mark.parametrize("namespace", list(IdentityNamespace)) +async def test_arc_attachment_preserves_scoped_keys_and_legacy_evidence( + identity_probe_db, namespace +): + _, factory, _ = identity_probe_db + first, second = await _arcs(factory) + async with factory.begin() as session: + session.add_all( + [ + StoryArcExternalIdentity( + story_arc_id=first, + source=namespace.value, + namespace="story_arc", + external_id="31", + source_url="https://example.invalid/31", + evidence={"imported_story_arc_id": 100}, + ), + StoryArcExternalIdentity( + story_arc_id=second, + source=namespace.value, + namespace="import-scope", + external_id="31", + ), + StoryArcExternalIdentity( + story_arc_id=first, + source="mylar3", + namespace="story_arc", + external_id="not-a-provider-id", + ), + ] + ) + request = _arc_request(first, namespace=namespace) + async with factory.begin() as session: + receipt = (await attach_verified_identities(session, [request]))[0] + assert not receipt.replayed + async with factory.begin() as session: + replay = ( + await attach_verified_identities(session, [request], require_current_ownership=True) + )[0] + assert replay.replayed and replay.event_id == receipt.event_id + rows = list( + await session.scalars( + select(StoryArcExternalIdentity).order_by(StoryArcExternalIdentity.id) + ) + ) + assert len(rows) == 3 + assert rows[0].verification_state == "verified" and rows[0].revision == 2 + assert rows[0].source_url == "https://example.invalid/31" + assert rows[0].evidence == {"imported_story_arc_id": 100} + assert rows[1].verification_state is rows[2].verification_state is None + assert rows[1].revision == rows[2].revision == 1 + assert (await session.get(StoryArc, first)).comicvine_id == ( + 31 if namespace is IdentityNamespace.COMICVINE else None + ) + history = await session.scalar(select(StoryArcIdentityEvent)) + assert history.request_json == prepare_identity_event(request).request_json + assert history.identity_namespace == namespace + + +async def test_arc_attachment_never_commits_and_rolls_back_mixed_graph(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _arcs(factory) + async with factory.begin() as session: + series = Series(title="Parent", sort_title="parent") + session.add(series) + await session.flush() + issue = Issue(series_id=series.id, issue_number=1) + session.add(issue) + await session.flush() + parent_id, issue_id = series.id, issue.id + requests = [_arc_request(first), _request(parent_id)] + async with factory() as session: + await attach_verified_identities(session, requests) + await session.rollback() + async with factory.begin() as session: + wrong_parent = ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "99" + ) + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities( + session, + [ + *requests, + _request(issue_id, "91", kind=MetadataEntityKind.ISSUE, parent=wrong_parent), + ], + ) + async with factory() as session: + for model in ( + StoryArcExternalIdentity, + StoryArcIdentityEvent, + SeriesIdentityEvent, + IssueIdentityEvent, + ): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + assert (await session.get(StoryArc, first)).comicvine_id is None + assert (await session.get(Series, parent_id)).comicvine_id is None + + +@pytest.mark.parametrize( + "collision", ["legacy_owner", "legacy_target", "active_owner", "active_target"] +) +async def test_arc_attachment_refuses_competing_ownership(identity_probe_db, collision): + _, factory, _ = identity_probe_db + first, second = await _arcs(factory) + async with factory.begin() as session: + target = second if collision.endswith("owner") else first + value = "31" if collision.endswith("owner") else "99" + if collision.startswith("legacy"): + await session.execute( + update(StoryArc).where(StoryArc.id == target).values(comicvine_id=int(value)) + ) + else: + session.add( + StoryArcExternalIdentity( + story_arc_id=target, + source="comicvine", + namespace="story_arc", + external_id=value, + ) + ) + async with factory.begin() as session: + with pytest.raises(IdentityAttachmentConflictError): + await attach_verified_identities(session, [_arc_request(first)]) + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 0 + + +@pytest.mark.parametrize("mode", ["retry", "observation", "competing_owner"]) +async def test_arc_concurrent_writers_serialize(identity_probe_db, mode): + _, factory, _ = identity_probe_db + first, second = await _arcs(factory) + original = _arc_request(first) + other = ( + original + if mode == "retry" + else _arc_request(second if mode == "competing_owner" else first) + ) + + async def attach(request): + try: + async with factory.begin() as session: + return (await attach_verified_identities(session, [request]))[0] + except IdentityAttachmentConflictError: + return "conflict" + + receipts = await asyncio.wait_for(asyncio.gather(attach(original), attach(other)), timeout=20) + assert receipts.count("conflict") == (1 if mode == "competing_owner" else 0) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(StoryArcExternalIdentity)) == 1 + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == ( + 2 if mode == "observation" else 1 + ) + + +async def test_arc_bulk_attachment_is_bounded_and_locks_before_series(identity_probe_db): + engine, factory, _ = identity_probe_db + arcs = await _arcs(factory, 210) + async with factory.begin() as session: + series = Series(title="Parent", sort_title="parent") + session.add(series) + await session.flush() + series_id = series.id + statements, binds = [], [] + + def track(_conn, _cursor, statement, parameters, _context, many): + statements.append(statement) + binds.append( + len(parameters[0]) if many and isinstance(parameters[0], tuple) else len(parameters) + ) + + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + async with factory.begin() as session: + receipts = await attach_verified_identities( + session, + [_request(series_id), *[_arc_request(i, str(i + 100)) for i in reversed(arcs)]], + ) + assert len(receipts) == 211 + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) + assert len(statements) < 65 + if engine.dialect.name == "sqlite": + assert max(binds) <= 999 + else: + locks = [statement for statement in statements if "FOR UPDATE" in statement] + assert "story_arcs" in locks[0] and "story_arcs" in locks[1] + assert "FROM series" in locks[2] + + +async def _adopt(factory, tmp_path): + provider = _provider() + service = StoryArcCatalogService(provider) + preview = await service.preview("31") + library = tmp_path / "comics" + library.mkdir() + async with factory.begin() as session: + root = await _root(session, library) + arc = await service.add( + session, preview, ordered_issue_provider_ids=["11", "12"], library_root_id=root.id + ) + return service, provider, preview, arc.id, arc.revision + + +async def test_catalog_arc_add_and_refresh_record_exact_evidence_without_more_fetches( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service, provider, preview, arc_id, revision = await _adopt(factory, tmp_path) + async with factory.begin() as session: + owner = await session.scalar(select(StoryArcExternalIdentity)) + assert owner.verification_state == "verified" and owner.evidence_kind == "provider_result" + history = await session.scalar(select(StoryArcIdentityEvent)) + assert json.loads(history.request_json)["origin"]["source_instance"] == "comicvine_api" + assert owner.evidence["snapshot_fingerprint"] == preview.fingerprint + assert (await session.get(StoryArc, arc_id)).comicvine_id == 31 + await service.refresh(session, arc_id, preview, expected_revision=revision) + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 1 + provider.get_story_arc.assert_awaited_once() + provider.get_story_arc_issues.assert_awaited_once() + assert provider.get_series.await_count == 2 + assert not list((tmp_path / "comics").iterdir()) + + +@pytest.mark.parametrize("state", ["released", "stale", "conflicted", "rejected"]) +async def test_catalog_arc_refresh_revalidates_its_own_identity(identity_probe_db, tmp_path, state): + _, factory, _ = identity_probe_db + service, _, preview, arc_id, revision = await _adopt(factory, tmp_path) + async with factory.begin() as session: + if state == "released": + await session.execute(delete(StoryArcExternalIdentity)) + elif state == "rejected": + original = await session.scalar(select(StoryArcIdentityEvent)) + assert original is not None, "Arc adoption must leave durable evidence" + request = prepare_identity_event(_arc_request(arc_id)) + session.add( + StoryArcIdentityEvent( + story_arc_id=arc_id, + identity_namespace="comicvine", + external_id="31", + verification_state="rejected", + evidence_kind="provider_result", + event_key=request.event_key, + request_fingerprint=request.request_fingerprint, + request_json=request.request_json, + ) + ) + else: + await session.execute(update(StoryArcExternalIdentity).values(verification_state=state)) + async with factory.begin() as session: + with pytest.raises(StoryArcCatalogError, match=r"identity.*review"): + await service.refresh(session, arc_id, preview, expected_revision=revision) + assert (await session.get(StoryArc, arc_id)).revision == revision + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 2 + + +async def test_fresh_arc_evidence_cannot_override_retained_rejection(identity_probe_db): + _, factory, _ = identity_probe_db + first, _ = await _arcs(factory) + request = _arc_request(first) + async with factory.begin() as session: + await attach_verified_identities(session, [request]) + await session.execute(update(StoryArcIdentityEvent).values(verification_state="rejected")) + async with factory.begin() as session: + with pytest.raises(IdentityReviewRequiredError): + await attach_verified_identities(session, [replace(request, operation_id=uuid4())]) + + +async def test_refresh_legacy_arc_keeps_import_provenance_and_missing_provider_fields( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service = StoryArcCatalogService(_provider()) + preview = await service.preview("31") + library = tmp_path / "comics" + library.mkdir() + async with factory.begin() as session: + root = await _root(session, library) + arc = StoryArc(name="Local arc name", comicvine_id=31, source_kind="mylar3") + session.add(arc) + await session.flush() + owner = StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id="31", + source_url="https://comicvine.gamespot.com/story-arc/4045-31/", + evidence={"import_job_id": 7}, + ) + session.add(owner) + await session.flush() + arc_id, root_id, revision = arc.id, root.id, arc.revision + async with factory.begin() as session: + await service.refresh( + session, arc_id, preview, expected_revision=revision, library_root_id=root_id + ) + async with factory() as session: + owner = await session.scalar(select(StoryArcExternalIdentity)) + assert owner.verification_state == "verified" and owner.revision == 2 + assert owner.source_url == "https://comicvine.gamespot.com/story-arc/4045-31/" + assert owner.evidence == {"import_job_id": 7, "snapshot_fingerprint": preview.fingerprint} + assert (await session.get(StoryArc, arc_id)).name == "Local arc name" + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 1 + + +@pytest.mark.parametrize("member_conflict", [False, True]) +async def test_arc_new_observation_and_members_are_one_rollback_boundary( + identity_probe_db, tmp_path, member_conflict +): + _, factory, _ = identity_probe_db + service, provider, _, arc_id, revision = await _adopt(factory, tmp_path) + provider.get_story_arc.return_value = replace( + provider.get_story_arc.return_value, cover_url="https://example.invalid/new-cover.jpg" + ) + preview = await service.preview("31") + if member_conflict: + async with factory.begin() as session: + await session.execute( + update(IssueExternalIdentity).values(verification_state="conflicted") + ) + async with factory.begin() as session: + if member_conflict: + with pytest.raises(StoryArcCatalogError): + await service.refresh(session, arc_id, preview, expected_revision=revision) + else: + await service.refresh(session, arc_id, preview, expected_revision=revision) + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.revision == revision + (0 if member_conflict else 1) + assert arc.cover_url == (None if member_conflict else preview.metadata.cover_url) + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == ( + 1 if member_conflict else 2 + ) + assert (await session.scalar(select(StoryArcExternalIdentity))).revision == ( + 1 if member_conflict else 2 + ) + + +async def test_arc_catalog_caller_rollback_removes_whole_identity_graph( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + service = StoryArcCatalogService(_provider()) + preview = await service.preview("31") + library = tmp_path / "comics" + library.mkdir() + async with factory.begin() as session: + root_id = (await _root(session, library)).id + async with factory() as session: + await service.add( + session, preview, ordered_issue_provider_ids=["11", "12"], library_root_id=root_id + ) + await session.rollback() + async with factory() as session: + for model in ( + StoryArc, + StoryArcExternalIdentity, + StoryArcIdentityEvent, + Series, + SeriesIdentityEvent, + Issue, + IssueIdentityEvent, + ): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + assert not list(library.iterdir()) From 4894d2af607435dc18599c22e91a9660a341feea Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 00:02:57 -0700 Subject: [PATCH 15/97] fix: normalize legacy story arc identities without losing evidence Align Mylar and folder evidence with canonical ComicVine resource IDs. Preserve raw references and migration history, reject ownership collisions before writes, and restore original keys on safe downgrade. --- ...l0m1n2o345_story_arc_identity_lifecycle.py | 170 +++++++++++-- src/pullbox/core/comicvine_arc_identity.py | 17 ++ .../import_story_arc_materialization.py | 16 +- .../services/import_story_arc_staging.py | 5 + .../services/metadata_identity_inventory.py | 3 + .../test_arc_identity_migration.py | 229 +++++++++++++++++- .../test_arc_import_identity_normalization.py | 165 +++++++++++++ tests/unit/test_comicvine_arc_identity.py | 55 +++++ .../test_import_story_arc_materialization.py | 2 +- tests/unit/test_import_story_arc_staging.py | 3 +- 10 files changed, 636 insertions(+), 29 deletions(-) create mode 100644 src/pullbox/core/comicvine_arc_identity.py create mode 100644 tests/integration/metadata_identity/test_arc_import_identity_normalization.py create mode 100644 tests/unit/test_comicvine_arc_identity.py diff --git a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py index 87b54895..eeb394ed 100644 --- a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py +++ b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py @@ -15,6 +15,7 @@ _NAME = "story_arc_external_identities" _PAGE = 200 _OPERATION = "593a9848-6071-40d7-9cd5-fffece1dc7f4" +_NORMALIZATION = "_pullbox_identity_normalization" _PROVIDERS = ("comicvine", "metron", "gcd", "locg") _STATES = ("observed", "verified", "conflicted", "stale", "rejected") _EVIDENCE = ( @@ -98,6 +99,70 @@ def _valid(value: str) -> bool: ) +def _canonical_id(source: str, value: str) -> str: + # Frozen compatibility parser: ComicVine's 4045 resource is a Story Arc. + if source == "comicvine" and len(value) <= 255: + text = value.strip(" ").removeprefix("4045-") + if text.isascii() and text.isdecimal(): + normalized = text.lstrip("0") + if normalized and int(normalized) < 2**63: + return normalized + elif source != "comicvine" and _valid(value): + return value + _fail() + + +def _untouched(row) -> bool: + return all(row[field] is None for field in _FIELDS if field != "revision") and row.revision == 1 + + +def _update_rows(active: sa.Table, rows: list[dict]) -> None: + if rows: + op.get_bind().execute( + sa.update(active) + .where(active.c.id == sa.bindparam("_row_id")) + .values(**{field: sa.bindparam(field) for field in rows[0] if field != "_row_id"}), + rows, + ) + + +def _normalization_original(row) -> str | None: + if not isinstance(row.evidence, dict): + _fail() + if _NORMALIZATION not in row.evidence: + return None + marker = row.evidence[_NORMALIZATION] + if ( + row.source != "comicvine" + or not isinstance(marker, dict) + or set(marker) != {"schema_version", "external_id"} + or type(marker["schema_version"]) is not int + or marker["schema_version"] != 1 + or not isinstance(marker["external_id"], str) + or marker["external_id"] == row.external_id + or _canonical_id(row.source, marker["external_id"]) != row.external_id + or not set(_FIELDS) <= row.keys() + or not ( + _untouched(row) + or _equal(row, _expected(row.story_arc_id, row.source, row.external_id)[0]) + ) + ): + _fail() + return marker["external_id"] + + +def _normalized_key(active: sa.Table): + trimmed = sa.func.trim(active.c.external_id, " ") + numeric = sa.case( + (sa.func.substr(trimmed, 1, 5) == "4045-", sa.func.substr(trimmed, 6)), + else_=trimmed, + ) + return sa.case( + (active.c.source == "comicvine", sa.func.ltrim(numeric, "0")), + else_=active.c.external_id, + ) + + def _preflight(arcs: sa.Table, active: sa.Table) -> None: conn = op.get_bind() if conn.execute( @@ -108,12 +173,29 @@ def _preflight(arcs: sa.Table, active: sa.Table) -> None: .limit(1) ).first(): _fail() + # Compare aliases across the entire table, not just the current page. + if conn.execute( + sa.select(active.c.source) + .where(_canonical(active)) + .group_by(active.c.source, _normalized_key(active)) + .having(sa.func.count() > 1) + .limit(1) + ).first(): + _fail() for rows in _pages(active, _canonical(active)): + canonical = {} for row in rows: - if not _valid(row.external_id) or ( - row.source == "comicvine" and int(row.external_id) >= 2**63 + canonical[row.id] = _canonical_id(row.source, row.external_id) + if ( + set(_FIELDS) <= row.keys() + and not _untouched(row) + and not ( + canonical[row.id] == row.external_id + and _equal(row, _expected(row.story_arc_id, row.source, row.external_id)[0]) + ) ): _fail() + _normalization_original(row) parents = dict( conn.execute( sa.select(arcs.c.id, arcs.c.comicvine_id).where( @@ -127,7 +209,7 @@ def _preflight(arcs: sa.Table, active: sa.Table) -> None: for local_id, value in conn.execute( sa.select(arcs.c.id, arcs.c.comicvine_id).where( sa.cast(arcs.c.comicvine_id, sa.BigInteger).in_( - [int(row.external_id) for row in cv_rows] + [int(canonical[row.id]) for row in cv_rows] ) ) ) @@ -136,16 +218,21 @@ def _preflight(arcs: sa.Table, active: sa.Table) -> None: if row.story_arc_id not in parents: _fail() if row.source == "comicvine" and ( - parents[row.story_arc_id] not in (None, int(row.external_id)) - or owners.get(int(row.external_id), row.story_arc_id) != row.story_arc_id + parents[row.story_arc_id] not in (None, int(canonical[row.id])) + or owners.get(int(canonical[row.id]), row.story_arc_id) != row.story_arc_id ): _fail() -def _expected(local_id: int, namespace: str, external_id: str) -> tuple[dict, dict]: +def _expected( + local_id: int, namespace: str, external_id: str, legacy_external_id: str | None = None +) -> tuple[dict, dict]: # Frozen v1 event envelope; do not import mutable runtime event policy. origin = {"record_kind": "story_arc", "record_id": local_id} - evidence_revision = _digest(f"legacy_arc:{namespace}:{local_id}:{external_id}") + revision_basis = f"legacy_arc:{namespace}:{local_id}:{external_id}" + if legacy_external_id is not None: + revision_basis += f":{legacy_external_id}" + evidence_revision = _digest(revision_basis) slot = { "version": 1, "operation_id": _OPERATION, @@ -173,6 +260,8 @@ def _expected(local_id: int, namespace: str, external_id: str) -> tuple[dict, di "actor_user_id": None, "review_revision": None, } + if legacy_external_id is not None: + payload["legacy_external_id"] = legacy_external_id serialized = _json(payload) owner = { "verification_state": "verified", @@ -203,23 +292,28 @@ def _validate_events(active: sa.Table, events: sa.Table) -> None: for rows in _pages(events): keys = [(row.story_arc_id, row.identity_namespace, row.external_id) for row in rows] owners = { - tuple(row): True - for row in op.get_bind().execute( - sa.select(active.c.story_arc_id, active.c.source, active.c.external_id).where( + (row.story_arc_id, row.source, row.external_id): row + for row in op.get_bind() + .execute( + sa.select(active).where( _canonical(active), sa.tuple_(active.c.story_arc_id, active.c.source, active.c.external_id).in_( keys ), ) ) + .mappings() } for row in rows: - if ( - row.story_arc_id, - row.identity_namespace, - row.external_id, - ) not in owners or not _equal( - row, _expected(row.story_arc_id, row.identity_namespace, row.external_id)[1] + owner = owners.get((row.story_arc_id, row.identity_namespace, row.external_id)) + if owner is None or not _equal( + row, + _expected( + row.story_arc_id, + row.identity_namespace, + row.external_id, + _normalization_original(owner), + )[1], ): _fail() @@ -230,6 +324,23 @@ def upgrade() -> None: _preflight(arcs, active) # Refuse prior decisions before SQLite performs non-transactional DDL. _validate_events(active, events) + # Only after every collision/history check passes may source keys change. + for rows in _pages(active, _canonical(active)): + updates = [] + for row in rows: + canonical = _canonical_id(row.source, row.external_id) + if canonical != row.external_id: + updates.append( + { + "_row_id": row.id, + "external_id": canonical, + "evidence": { + **row.evidence, + _NORMALIZATION: {"schema_version": 1, "external_id": row.external_id}, + }, + } + ) + _update_rows(active, updates) existing = {col["name"] for col in sa.inspect(conn).get_columns(_NAME)} columns = ( sa.Column("verification_state", sa.String(max(map(len, _STATES)))), @@ -270,7 +381,13 @@ def upgrade() -> None: conn.execute(sa.insert(active), values) for rows in _pages(active, _canonical(active)): expected = [ - (row, *_expected(row.story_arc_id, row.source, row.external_id)) for row in rows + ( + row, + *_expected( + row.story_arc_id, row.source, row.external_id, _normalization_original(row) + ), + ) + for row in rows ] prior = { row.event_key: row @@ -281,20 +398,19 @@ def upgrade() -> None: ).mappings() } creates = [] + updates = [] for row, owner, event in expected: - untouched = ( - all(row[field] is None for field in _FIELDS if field != "revision") - and row.revision == 1 - ) + untouched = _untouched(row) if not untouched and not _equal(row, owner): _fail() if untouched: - conn.execute(sa.update(active).where(active.c.id == row.id).values(**owner)) + updates.append({"_row_id": row.id, **owner}) if event["event_key"] in prior: if not _equal(prior[event["event_key"]], event): _fail() else: creates.append(event) + _update_rows(active, updates) if creates: conn.execute(sa.insert(events), creates) checks = {row["name"] for row in sa.inspect(conn).get_check_constraints(_NAME)} @@ -331,6 +447,16 @@ def downgrade() -> None: if not _equal(row, expected): _fail() op.get_bind().execute(sa.delete(events)) + for rows in _pages(active, _canonical(active)): + updates = [] + for row in rows: + original = _normalization_original(row) + if original is not None: + evidence = { + key: value for key, value in row.evidence.items() if key != _NORMALIZATION + } + updates.append({"_row_id": row.id, "external_id": original, "evidence": evidence}) + _update_rows(active, updates) op.drop_index("uq_story_arc_canonical_provider", table_name=_NAME) with op.batch_alter_table(_NAME) as batch: for name in _CHECKS: diff --git a/src/pullbox/core/comicvine_arc_identity.py b/src/pullbox/core/comicvine_arc_identity.py new file mode 100644 index 00000000..6f32092f --- /dev/null +++ b/src/pullbox/core/comicvine_arc_identity.py @@ -0,0 +1,17 @@ +"""ComicVine Story Arc identifiers at legacy import boundaries.""" + +from __future__ import annotations + + +def normalize_comicvine_arc_id(value: object) -> str: + """Normalize an exact numeric ID or typed 4045 resource ID, never a URL.""" + if type(value) is int: + if 0 < value < 2**63: + return str(value) + elif isinstance(value, str) and len(value) <= 255: + text = value.strip(" ").removeprefix("4045-") + if text.isascii() and text.isdecimal(): + normalized = text.lstrip("0") + if normalized and int(normalized) < 2**63: + return normalized + raise ValueError("Invalid ComicVine Story Arc ID") diff --git a/src/pullbox/services/import_story_arc_materialization.py b/src/pullbox/services/import_story_arc_materialization.py index 14cf9b95..139fc0ca 100644 --- a/src/pullbox/services/import_story_arc_materialization.py +++ b/src/pullbox/services/import_story_arc_materialization.py @@ -13,6 +13,7 @@ from sqlalchemy import func, or_, select, tuple_ from sqlalchemy.orm import selectinload +from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.story_arc_naming import ( validate_story_arc_file_template, @@ -466,6 +467,7 @@ async def _load_batch_external_identity_evidence( """Scan provider-neutral entry evidence without materializing entry ORM rows.""" staged_arc_ids = [int(staged_arc.id) for staged_arc in staged_arcs] comicvine_ids: dict[int, set[str]] = {arc_id: set() for arc_id in staged_arc_ids} + invalid_arc_ids: set[int] = set() row_count = 0 result = await session.stream( select( @@ -483,8 +485,14 @@ async def _load_batch_external_identity_evidence( async for imported_story_arc_id, evidence in result: values = comicvine_ids[int(imported_story_arc_id)] cv_arc_id = _mapping(evidence).get("cv_arc_id") - if cv_arc_id is not None and str(cv_arc_id).strip() and len(values) < 2: - values.add(str(cv_arc_id)) + if cv_arc_id is not None: + try: + normalized = normalize_comicvine_arc_id(cv_arc_id) + except ValueError: + invalid_arc_ids.add(int(imported_story_arc_id)) + else: + if len(values) < 2: + values.add(normalized) row_count += 1 if row_count % entry_page_size == 0: await _checkpoint(cancellation_check) @@ -498,7 +506,9 @@ async def _load_batch_external_identity_evidence( identities.append((staged_arc.source_kind.value, "story_arc", staged_arc.source_arc_id)) values = comicvine_ids[int(staged_arc.id)] warning_code = None - if len(values) == 1: + if int(staged_arc.id) in invalid_arc_ids: + warning_code = "invalid_external_identity_evidence" + elif len(values) == 1: identities.append(("comicvine", "story_arc", next(iter(values)))) elif len(values) > 1: warning_code = "conflicting_external_identity_evidence" diff --git a/src/pullbox/services/import_story_arc_staging.py b/src/pullbox/services/import_story_arc_staging.py index 4fffa8d7..6b6aa493 100644 --- a/src/pullbox/services/import_story_arc_staging.py +++ b/src/pullbox/services/import_story_arc_staging.py @@ -6,12 +6,14 @@ import json import re from collections.abc import Awaitable, Callable +from contextlib import suppress from dataclasses import dataclass from decimal import Decimal, InvalidOperation from typing import TYPE_CHECKING, cast from sqlalchemy import select +from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id from pullbox.core.mylar_story_arc_policy import build_mylar_story_arc_policy_draft from pullbox.models.import_job import ImportedFile, ImportedFileStatus from pullbox.models.story_arc import ( @@ -791,6 +793,9 @@ def _mylar_external_identities( external_id = _bounded_identifier(source_arc.cv_arc_id, 255) if external_id is None: return [] + # Retain invalid source evidence for review; materialization refuses it. + with suppress(ValueError): + external_id = normalize_comicvine_arc_id(external_id) return [ { "source": "comicvine", diff --git a/src/pullbox/services/metadata_identity_inventory.py b/src/pullbox/services/metadata_identity_inventory.py index 81a6384e..d9980291 100644 --- a/src/pullbox/services/metadata_identity_inventory.py +++ b/src/pullbox/services/metadata_identity_inventory.py @@ -9,6 +9,7 @@ from sqlalchemy import select +from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id from pullbox.core.metadata_identity import ( ExactIdentityConflict, ExactIdentityEvidence, @@ -157,6 +158,8 @@ async def read_legacy_identity_page( for row_id, local_id, source, value in arc_rows: storage = LegacyIdentityStorage.STORY_ARC_RELATION try: + if source == IdentityNamespace.COMICVINE: + value = normalize_comicvine_arc_id(value) identity = ExternalIdentityRef(IdentityNamespace(source), entity_kind, value) except ValueError: problems.append( diff --git a/tests/integration/metadata_identity/test_arc_identity_migration.py b/tests/integration/metadata_identity/test_arc_identity_migration.py index 2e53d024..8ba8a5f4 100644 --- a/tests/integration/metadata_identity/test_arc_identity_migration.py +++ b/tests/integration/metadata_identity/test_arc_identity_migration.py @@ -18,6 +18,7 @@ String, Table, UniqueConstraint, + event, func, insert, inspect, @@ -156,7 +157,7 @@ async def test_arc_migration_backfills_proof_and_preserves_import_scopes(identit @pytest.mark.parametrize( - "problem", ["duplicate_provider", "column_disagreement", "other_owner", "padded", "invalid"] + "problem", ["duplicate_provider", "column_disagreement", "other_owner", "wrong_type", "invalid"] ) async def test_arc_migration_refuses_ambiguous_legacy_evidence_before_ddl( identity_probe_db, problem @@ -179,7 +180,7 @@ async def test_arc_migration_refuses_ambiguous_legacy_evidence_before_ddl( elif problem == "other_owner": rows[0]["story_arc_id"] = second else: - rows[0]["external_id"] = "0031" if problem == "padded" else "unparseable" + rows[0]["external_id"] = "4050-31" if problem == "wrong_type" else "unparseable" async with engine.begin() as connection: await connection.execute(insert(old), rows) before = (await connection.execute(select(old))).all() @@ -192,6 +193,230 @@ async def test_arc_migration_refuses_ambiguous_legacy_evidence_before_ddl( assert not _NEW & columns +@pytest.mark.parametrize("raw", ["4045-31", "0031", " 4045-0031 "]) +async def test_arc_migration_normalizes_proven_legacy_keys_reversibly(identity_probe_db, raw): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, _ = await _arcs(factory) + async with engine.begin() as connection: + await connection.execute( + insert(old).values( + story_arc_id=first, + source="comicvine", + namespace="story_arc", + external_id=raw, + source_url="https://comicvine.gamespot.com/arc/4045-31/", + evidence={"imported_story_arc_id": 17, "unknown": {"keep": True}}, + ) + ) + before = (await connection.execute(select(old))).mappings().one() + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + active = Base.metadata.tables[_NAME] + events = Base.metadata.tables["story_arc_identity_events"] + after = (await connection.execute(select(active))).mappings().one() + assert after.external_id == "31" + assert after.id == before.id + # Use the same legacy projection, not different UTC result processors. + assert await connection.scalar(select(old.c.created_at)) == before.created_at + assert after.source_url == before.source_url + assert after.evidence["unknown"] == before.evidence["unknown"] + assert after.evidence["_pullbox_identity_normalization"] == { + "schema_version": 1, + "external_id": raw, + } + assert after.verification_state == "verified" + event = (await connection.execute(select(events))).mappings().one() + assert json.loads(event.request_json)["identity"]["external_id"] == "31" + assert json.loads(event.request_json)["legacy_external_id"] == raw + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert await connection.scalar(select(func.count()).select_from(events)) == 1 + await connection.run_sync(lambda conn: _migration(conn).downgrade()) + assert (await connection.execute(select(old))).mappings().one() == before + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert await connection.scalar(select(active.c.external_id)) == "31" + + +@pytest.mark.parametrize( + "problem", ["alias_owner", "alias_same_owner", "column_owner", "bad_late", "marker"] +) +async def test_arc_normalization_refuses_ambiguity_before_any_write(identity_probe_db, problem): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + ids = await _arcs(factory, (None,) * 205) + rows = [ + { + "story_arc_id": local_id, + "source": "comicvine", + "namespace": "story_arc", + "external_id": f"4045-{index + 100}", + "evidence": {}, + } + for index, local_id in enumerate(ids) + ] + if problem in {"alias_owner", "alias_same_owner"}: + rows[-1]["external_id"] = "00100" + if problem == "alias_same_owner": + rows[-1]["story_arc_id"] = ids[0] + elif problem == "bad_late": + rows[-1]["external_id"] = "4000-100" + elif problem == "marker": + rows[-1]["evidence"] = {"_pullbox_identity_normalization": {"user": "data"}} + async with engine.begin() as connection: + await connection.execute(insert(old), rows) + if problem == "column_owner": + await connection.execute( + update(StoryArc).where(StoryArc.id == ids[-1]).values(comicvine_id=100) + ) + before = (await connection.execute(select(old).order_by(old.c.id))).all() + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert (await connection.execute(select(old).order_by(old.c.id))).all() == before + columns = await connection.run_sync( + lambda conn: {c["name"] for c in inspect(conn).get_columns(_NAME)} + ) + assert not _NEW & columns + + +async def test_frozen_arc_parser_matches_runtime_boundary(identity_probe_db): + from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id + + engine, _, _ = identity_probe_db + valid = ["12", "00012", " 4045-00012 ", f"4045-{2**63 - 1}"] + invalid = ["", " ", "4045-0", "4000-12", "4045- 12", "4045-12/", "\t12", "\uff11", str(2**63)] + async with engine.connect() as connection: + for value in valid: + result = await connection.run_sync( + lambda conn, value=value: _migration(conn)._canonical_id("comicvine", value) + ) + assert result == normalize_comicvine_arc_id(value) + for value in invalid: + with pytest.raises(ValueError): + normalize_comicvine_arc_id(value) + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync( + lambda conn, value=value: _migration(conn)._canonical_id("comicvine", value) + ) + + +async def test_normalization_downgrade_preserves_later_decisions(identity_probe_db): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, _ = await _arcs(factory) + async with engine.begin() as connection: + await connection.execute( + insert(old).values( + story_arc_id=first, + source="comicvine", + namespace="story_arc", + external_id="4045-31", + ) + ) + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + active = Base.metadata.tables[_NAME] + await connection.execute(update(active).values(verification_state="conflicted", revision=2)) + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).downgrade()) + assert await connection.scalar(select(active.c.external_id)) == "31" + assert await connection.scalar(select(active.c.verification_state)) == "conflicted" + + +async def test_arc_migration_checks_all_current_proof_before_normalizing(identity_probe_db): + engine, factory, _ = identity_probe_db + first, second = await _arcs(factory, (None, None)) + active = Base.metadata.tables[_NAME] + async with engine.begin() as connection: + await connection.execute( + insert(active), + [ + { + "story_arc_id": first, + "source": "comicvine", + "namespace": "story_arc", + "external_id": "4045-31", + }, + { + "story_arc_id": second, + "source": "comicvine", + "namespace": "story_arc", + "external_id": "32", + }, + ], + ) + await connection.execute( + update(active) + .where(active.c.story_arc_id == second) + .values(verification_state="conflicted", revision=2) + ) + before = (await connection.execute(select(active).order_by(active.c.id))).all() + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert (await connection.execute(select(active).order_by(active.c.id))).all() == before + + +async def test_arc_migration_preserves_existing_history_before_normalizing(identity_probe_db): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + first, _ = await _arcs(factory) + events = Base.metadata.tables["story_arc_identity_events"] + async with engine.begin() as connection: + await connection.execute( + insert(old).values( + story_arc_id=first, source="comicvine", namespace="story_arc", external_id="4045-31" + ) + ) + receipt = await connection.run_sync( + lambda conn: _migration(conn)._expected(first, "comicvine", "31")[1] + ) + await connection.execute(insert(events).values(**receipt)) + before = (await connection.execute(select(old))).all() + with pytest.raises(RuntimeError, match="review"): + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + assert (await connection.execute(select(old))).all() == before + assert await connection.scalar(select(func.count()).select_from(events)) == 1 + + +async def test_arc_normalization_pages_large_legacy_libraries(identity_probe_db): + engine, factory, _ = identity_probe_db + old = await _old_schema(engine) + ids = await _arcs(factory, (None,) * 210) + maximum_binds = 0 + query_count = 0 + + def track(_conn, _cursor, _statement, parameters, _context, many): + nonlocal maximum_binds, query_count + query_count += 1 + maximum_binds = max( + maximum_binds, + max((len(row) for row in parameters), default=0) if many else len(parameters), + ) + + async with engine.begin() as connection: + await connection.execute( + insert(old), + [ + { + "story_arc_id": local_id, + "source": "comicvine", + "namespace": "story_arc", + "external_id": f"4045-{i + 100}", + } + for i, local_id in enumerate(ids) + ], + ) + before = (await connection.execute(select(old).order_by(old.c.id))).all() + event.listen(engine.sync_engine, "before_cursor_execute", track) + try: + await connection.run_sync(lambda conn: _migration(conn).upgrade()) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", track) + assert maximum_binds <= 999 + assert query_count < 650 + events = Base.metadata.tables["story_arc_identity_events"] + assert await connection.scalar(select(func.count()).select_from(events)) == 210 + await connection.run_sync(lambda conn: _migration(conn).downgrade()) + assert (await connection.execute(select(old).order_by(old.c.id))).all() == before + + @pytest.mark.parametrize("change", ["state", "history", "revision", "scoped_lifecycle"]) async def test_arc_downgrade_refuses_to_discard_later_decisions(identity_probe_db, change): engine, factory, _ = identity_probe_db diff --git a/tests/integration/metadata_identity/test_arc_import_identity_normalization.py b/tests/integration/metadata_identity/test_arc_import_identity_normalization.py new file mode 100644 index 00000000..e1246a43 --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_import_identity_normalization.py @@ -0,0 +1,165 @@ +"""Imports compare canonical arc claims without rewriting saved source evidence.""" + +import pytest +from sqlalchemy import func, select + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models import StoryArc, StoryArcExternalIdentity +from pullbox.models.story_arc import ImportedStoryArcStatus, StoryArcSourceKind +from pullbox.services.import_story_arc_materialization import materialize_confirmed_story_arcs +from pullbox.services.metadata_identity_audit import audit_legacy_identities +from tests.unit.test_import_story_arc_materialization import ( + _add_job, + _add_staged_arc, + _add_staged_entry, +) + + +@pytest.mark.parametrize("source", [StoryArcSourceKind.MYLAR3, StoryArcSourceKind.FOLDER]) +@pytest.mark.parametrize("bad", [None, "4000-12", "4045-13", True, "4045-12/", " "]) +async def test_import_compares_all_selected_evidence_before_any_arc_write( + identity_probe_db, source, bad +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job = await _add_job(session) + staged = await _add_staged_arc( + session, + job=job, + name="Imported", + source_key="source:one", + source_arc_id="local-one", + source_kind=source, + ) + raw_values = ["4045-12", "00012", bad] if bad is not None else ["4045-12", "00012", " 12 "] + entries = [] + for ordinal, value in enumerate(raw_values, 1): + entries.append( + await _add_staged_entry( + session, + staged_arc=staged, + source_ordinal=ordinal, + reading_order=ordinal, + issue_number_text=str(ordinal), + cv_arc_id=value, + ) + ) + excluded = await _add_staged_entry( + session, + staged_arc=staged, + source_ordinal=4, + reading_order=4, + issue_number_text="4", + cv_arc_id="4045-999", + selected=False, + ) + other = await _add_staged_arc( + session, + job=job, + name="Unrelated", + source_key="source:two", + source_arc_id="local-two", + source_kind=source, + ) + await _add_staged_entry( + session, + staged_arc=other, + source_ordinal=1, + reading_order=1, + issue_number_text="1", + cv_arc_id=None, + ) + result = await materialize_confirmed_story_arcs( + session, import_job_id=job.id, entry_checkpoint_size=1 + ) + assert other.status == ImportedStoryArcStatus.IMPORTED + assert [row.evidence["cv_arc_id"] for row in entries] == raw_values + assert excluded.evidence["cv_arc_id"] == "4045-999" + if bad is None: + assert result.arcs_created == 2 and result.arcs_failed == 0 + owner = await session.scalar( + select(StoryArcExternalIdentity).where( + StoryArcExternalIdentity.source == "comicvine" + ) + ) + assert owner.external_id == "12" + assert staged.status == ImportedStoryArcStatus.IMPORTED + else: + assert result.arcs_created == 1 and result.arcs_failed == 1 + assert staged.status == ImportedStoryArcStatus.FAILED + assert staged.materialized_story_arc_id is None + expected = ( + "conflicting_external_identity_evidence" + if bad == "4045-13" + else "invalid_external_identity_evidence" + ) + assert expected in {warning.code for warning in result.warnings} + assert ( + await session.scalar( + select(func.count()) + .select_from(StoryArcExternalIdentity) + .where(StoryArcExternalIdentity.external_id == "local-one") + ) + == 0 + ) + + +@pytest.mark.parametrize("explicit", [False, True]) +async def test_import_finds_existing_numeric_identity_instead_of_creating_an_alias( + identity_probe_db, explicit +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job = await _add_job(session) + arc = StoryArc(name="Existing", comicvine_id=12) + session.add(arc) + await session.flush() + session.add( + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id="12" + ) + ) + staged = await _add_staged_arc( + session, + job=job, + name="Imported", + source_key="source:one", + source_arc_id=None, + proposed_story_arc_id=arc.id if explicit else None, + ) + await _add_staged_entry( + session, + staged_arc=staged, + source_ordinal=1, + reading_order=1, + issue_number_text="1", + cv_arc_id="4045-12", + ) + result = await materialize_confirmed_story_arcs(session, import_job_id=job.id) + assert result.arcs_created == 0 + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 1 + assert await session.scalar(select(func.count()).select_from(StoryArcExternalIdentity)) == 1 + assert result.arcs_merged == int(explicit) + assert result.arcs_failed == int(not explicit) + + +async def test_legacy_audit_detects_resource_alias_collision_without_mutation(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + arcs = [StoryArc(name="First"), StoryArc(name="Second")] + session.add_all(arcs) + await session.flush() + rows = [ + StoryArcExternalIdentity( + story_arc_id=arc.id, source="comicvine", namespace="story_arc", external_id=value + ) + for arc, value in zip(arcs, ["4045-12", "12"], strict=True) + ] + session.add_all(rows) + await session.flush() + audit = await audit_legacy_identities(session, MetadataEntityKind.STORY_ARC, page_size=1) + assert audit.complete and audit.has_blockers + assert not audit.problems + assert len(audit.collisions) == 1 + assert audit.collisions[0].identity.external_id == "12" + assert [row.external_id for row in rows] == ["4045-12", "12"] diff --git a/tests/unit/test_comicvine_arc_identity.py b/tests/unit/test_comicvine_arc_identity.py new file mode 100644 index 00000000..96586082 --- /dev/null +++ b/tests/unit/test_comicvine_arc_identity.py @@ -0,0 +1,55 @@ +"""Legacy arc references normalize only the documented ComicVine resource type.""" + +import pytest + +from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id + + +@pytest.mark.parametrize("value", [12, "12", "00012", " 12 ", "4045-12", " 4045-00012 "]) +def test_exact_legacy_arc_forms_share_one_key(value): + assert normalize_comicvine_arc_id(value) == "12" + + +@pytest.mark.parametrize( + "value", + [ + None, + True, + False, + 0, + -12, + 12.0, + "", + " ", + "0", + "4045-0", + "4045--12", + "4050-12", + "4000-12", + "4045-12/", + "https://comicvine.gamespot.com/arc/4045-12/", + "12x", + "12.0", + "+12", + "1e2", + "\uff11\uff12", + "4045-\uff11\uff12", + "\t12", + "12\n", + "4045- 12", + "4 045-12", + "0" * 255 + "12", + 2**63, + str(2**63), + f"4045-{2**63}", + {}, + [], + ], +) +def test_other_syntax_is_not_interpreted_as_an_arc_identity(value): + with pytest.raises(ValueError, match="Story Arc ID"): + normalize_comicvine_arc_id(value) + + +def test_maximum_compatibility_id_is_exact(): + assert normalize_comicvine_arc_id(f"4045-{2**63 - 1}") == str(2**63 - 1) diff --git a/tests/unit/test_import_story_arc_materialization.py b/tests/unit/test_import_story_arc_materialization.py index 7014d5c2..57334bd5 100644 --- a/tests/unit/test_import_story_arc_materialization.py +++ b/tests/unit/test_import_story_arc_materialization.py @@ -373,7 +373,7 @@ async def test_materializes_exact_ordered_entries_identities_and_multiple_arcs( ] assert {item.issue_id for item in memberships if item.issue_id == million.id} == {million.id} assert {(item.source, item.namespace, item.external_id) for item in identities} == { - ("comicvine", "story_arc", "4045-12"), + ("comicvine", "story_arc", "12"), ("mylar3", "story_arc", "arc-local-1"), ("mylar3", "story_arc", "arc-local-2"), } diff --git a/tests/unit/test_import_story_arc_staging.py b/tests/unit/test_import_story_arc_staging.py index 5655efdf..324a1e68 100644 --- a/tests/unit/test_import_story_arc_staging.py +++ b/tests/unit/test_import_story_arc_staging.py @@ -226,10 +226,11 @@ async def test_mylar_staging_preserves_exact_gapped_duplicate_and_missing_entrie { "source": "comicvine", "namespace": "story_arc", - "external_id": "4045-12", + "external_id": "12", } ] assert [entry.reading_order for entry in entries] == [2, 7, 7] + assert entries[0].evidence["cv_arc_id"] == "4045-12" assert [entry.reading_order_raw for entry in entries] == ["002", "007", "7"] assert [entry.source_issue_number_text for entry in entries] == [ "1000000", From 3151b7e68902de46e18dff4936cbce33496343be Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 00:50:48 -0700 Subject: [PATCH 16/97] feat: preserve story arc import identity history through rollback --- src/pullbox/core/metadata_identity_events.py | 2 + src/pullbox/models/base.py | 12 +- src/pullbox/services/import_job_actions.py | 57 +- .../services/import_story_arc_identity.py | 399 +++++++++++++ .../import_story_arc_materialization.py | 164 +++++- .../test_arc_import_history.py | 539 ++++++++++++++++++ .../test_import_story_arc_materialization.py | 2 +- tests/unit/test_utc_datetime.py | 34 ++ 8 files changed, 1188 insertions(+), 21 deletions(-) create mode 100644 src/pullbox/services/import_story_arc_identity.py create mode 100644 tests/integration/metadata_identity/test_arc_import_history.py create mode 100644 tests/unit/test_utc_datetime.py diff --git a/src/pullbox/core/metadata_identity_events.py b/src/pullbox/core/metadata_identity_events.py index e25cf840..f5519102 100644 --- a/src/pullbox/core/metadata_identity_events.py +++ b/src/pullbox/core/metadata_identity_events.py @@ -24,6 +24,8 @@ class IdentityEvidenceRecordKind(enum.StrEnum): STORY_ARC_IDENTITY = "story_arc_identity" IMPORTED_SERIES = "imported_series" IMPORTED_FILE = "imported_file" + IMPORTED_STORY_ARC = "imported_story_arc" + IMPORT_JOB_ACTION = "import_job_action" LIBRARY_FILE = "library_file" diff --git a/src/pullbox/models/base.py b/src/pullbox/models/base.py index e3a56266..e659899f 100644 --- a/src/pullbox/models/base.py +++ b/src/pullbox/models/base.py @@ -21,8 +21,9 @@ class UTCDateTime(TypeDecorator[datetime]): so Pydantic serializes it with +00:00/Z and downstream code never has to guess whether a timestamp is UTC or local. - Write path: strips tzinfo before storage (SQLite ignores it anyway). - Read path: attaches UTC tzinfo to naive datetimes coming from SQLite. + SQLite stores naive UTC. PostgreSQL receives aware UTC so its timestamptz + driver cannot interpret a naive value in the process's local timezone. + Naive input and database values are interpreted as UTC, never local time. """ impl = DateTime(timezone=True) @@ -32,9 +33,8 @@ def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | """Normalize to UTC before storing.""" if value is None: return None - if value.tzinfo is not None: - return value.astimezone(UTC).replace(tzinfo=None) - return value + normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return normalized if dialect.name == "postgresql" else normalized.replace(tzinfo=None) def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None: """Tag naive datetimes from the DB as UTC.""" @@ -42,7 +42,7 @@ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime return None if value.tzinfo is None: return value.replace(tzinfo=UTC) - return value + return value.astimezone(UTC) class Base(DeclarativeBase): diff --git a/src/pullbox/services/import_job_actions.py b/src/pullbox/services/import_job_actions.py index def26bfd..d8da0267 100644 --- a/src/pullbox/services/import_job_actions.py +++ b/src/pullbox/services/import_job_actions.py @@ -10,9 +10,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from sqlalchemy import false, or_ from sqlalchemy import func as sa_func from sqlalchemy import insert as sa_insert -from sqlalchemy import or_ from sqlalchemy import select as sa_select from sqlalchemy import update as sa_update @@ -21,6 +21,7 @@ build_file_identity_signature, build_managed_placement_signature, ) +from pullbox.core.metadata_identity import IdentityNamespace from pullbox.models.blocklist import BlocklistEntry from pullbox.models.direct_acquisition import DirectAcquisitionAttempt from pullbox.models.download import DownloadHistory @@ -40,6 +41,7 @@ LibraryRoot, MatchConfidence, ) +from pullbox.models.metadata_identity import StoryArcIdentityEvent from pullbox.models.pending_match import PendingMatch from pullbox.models.reader import IssueReaderState from pullbox.models.search_log import SearchLog @@ -56,6 +58,11 @@ ) from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry from pullbox.models.story_arc_sync import StoryArcSyncWork, StoryArcSyncWorkState +from pullbox.services.import_story_arc_identity import ( + IDENTITY_VERIFIED_ACTION, + require_owned_arc_identity_history, + rollback_import_arc_identity, +) from pullbox.utilities.settings import restore_file_from_utility_trash if TYPE_CHECKING: @@ -788,6 +795,16 @@ async def rollback_action( await _rollback_updated_story_arc_membership(session, action, payload) elif action_type == "story_arc_external_identity_created": await _rollback_created_story_arc_external_identity(session, action, payload) + elif action_type == IDENTITY_VERIFIED_ACTION: + await _require_story_arc_ownership( + session, + action=action, + imported_story_arc_id=_positive_int( + payload.get("imported_story_arc_id"), "imported_story_arc_id" + ), + story_arc_id=_positive_int(payload.get("story_arc_id"), "story_arc_id"), + ) + await rollback_import_arc_identity(session, action) elif action_type == "story_arc_policy_updated": await _rollback_story_arc_policy_update(session, action, payload) elif action_type == "story_arc_created": @@ -2619,7 +2636,12 @@ async def _rollback_created_story_arc_external_identity( identity_id = _positive_int(payload.get("external_identity_id"), "external_identity_id") story_arc_id = _positive_int(payload.get("story_arc_id"), "story_arc_id") expected_after = _payload_mapping(payload, "expected_after") - identity = await session.get(StoryArcExternalIdentity, identity_id) + if session.get_bind().dialect.name == "sqlite": + await session.execute(sa_update(StoryArc).where(false()).values(revision=StoryArc.revision)) + await session.execute( + sa_select(StoryArc.id).where(StoryArc.id == story_arc_id).with_for_update() + ) + identity = await session.get(StoryArcExternalIdentity, identity_id, populate_existing=True) if identity is None: return await _require_story_arc_ownership( @@ -2635,6 +2657,25 @@ async def _rollback_created_story_arc_external_identity( or _external_identity_state(identity) != expected_after ): raise ValueError("Story-arc external identity changed after import; rollback refused") + if identity.source in set(IdentityNamespace) and identity.namespace == "story_arc": + history = await session.scalar( + sa_select(StoryArcIdentityEvent.id) + .where( + StoryArcIdentityEvent.story_arc_id == story_arc_id, + StoryArcIdentityEvent.identity_namespace == identity.source, + ) + .limit(1) + ) + if ( + identity.verification_state is not None + or identity.evidence_kind is not None + or identity.evidence_locator is not None + or identity.verified_at is not None + or identity.last_seen_at is not None + or identity.revision != 1 + or history is not None + ): + raise ValueError("Story-arc external identity changed after import; rollback refused") await session.delete(identity) @@ -2669,7 +2710,14 @@ async def _rollback_created_story_arc( ) -> None: story_arc_id = _positive_int(payload.get("story_arc_id"), "story_arc_id") expected_after = _payload_mapping(payload, "expected_after") - arc = await session.get(StoryArc, story_arc_id) + if session.get_bind().dialect.name == "sqlite": + await session.execute(sa_update(StoryArc).where(false()).values(revision=StoryArc.revision)) + arc = await session.scalar( + sa_select(StoryArc) + .where(StoryArc.id == story_arc_id) + .with_for_update() + .execution_options(populate_existing=True) + ) if arc is None: return await _require_story_arc_ownership( @@ -2714,6 +2762,9 @@ async def _rollback_created_story_arc( ) if membership_count or identity_count or placement_count: raise ValueError("Story arc still has related rows; rollback refused") + await require_owned_arc_identity_history( + session, arc_id=story_arc_id, import_job_id=action.import_job_id + ) await session.delete(arc) diff --git a/src/pullbox/services/import_story_arc_identity.py b/src/pullbox/services/import_story_arc_identity.py new file mode 100644 index 00000000..d366aed6 --- /dev/null +++ b/src/pullbox/services/import_story_arc_identity.py @@ -0,0 +1,399 @@ +"""Verified import arc identities and guarded, history-preserving journal undo.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from typing import TYPE_CHECKING, Any +from uuid import NAMESPACE_URL, uuid5 + +from sqlalchemy import false, select, update + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventEvidence, + IdentityEventRequest, + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + prepare_identity_event, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationAction, + IdentityVerificationState, +) +from pullbox.models.import_job import ImportJobAction +from pullbox.models.metadata_identity import StoryArcIdentityEvent +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity, StoryArcSourceKind +from pullbox.services.metadata_identity_attachment import attach_verified_identities + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from pullbox.models.import_job import ImportJob + from pullbox.models.story_arc_import import ImportedStoryArc + from pullbox.services.import_job_execution_types import RecordActionFunc + +IDENTITY_VERIFIED_ACTION = "story_arc_identity_verified" + + +def _json(value: object) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _digest(value: object) -> str: + return hashlib.sha256(_json(value).encode("ascii")).hexdigest() + + +def identity_state(identity: StoryArcExternalIdentity) -> dict[str, Any]: + """Persist a complete proof snapshot, including the monotonic revision.""" + return { + "story_arc_id": identity.story_arc_id, + "source": identity.source, + "namespace": identity.namespace, + "external_id": identity.external_id, + "source_url": identity.source_url, + "evidence": identity.evidence, + "verification_state": identity.verification_state, + "evidence_kind": identity.evidence_kind, + "evidence_locator": identity.evidence_locator, + "verified_at": identity.verified_at.isoformat() if identity.verified_at else None, + "last_seen_at": identity.last_seen_at.isoformat() if identity.last_seen_at else None, + "created_at": identity.created_at.isoformat(), + "revision": identity.revision, + } + + +async def _latest_event( + session: AsyncSession, arc_id: int, source: str +) -> StoryArcIdentityEvent | None: + result: StoryArcIdentityEvent | None = await session.scalar( + select(StoryArcIdentityEvent) + .where( + StoryArcIdentityEvent.story_arc_id == arc_id, + StoryArcIdentityEvent.identity_namespace == source, + ) + .order_by(StoryArcIdentityEvent.id.desc()) + .limit(1) + .execution_options(populate_existing=True) + ) + return result + + +async def attach_import_arc_identity( + session: AsyncSession, + *, + arc: StoryArc, + staged: ImportedStoryArc, + external_id: str, + evidence_revision: str, + job: ImportJob, + record_action: RecordActionFunc | None, +) -> str: + """Attach exact saved evidence without treating a source key as a provider ID.""" + # The materializer owns an outer per-arc savepoint and locks existing arcs. + await session.flush() + await session.refresh(arc) + owner = await session.scalar( + select(StoryArcExternalIdentity) + .where( + StoryArcExternalIdentity.story_arc_id == arc.id, + StoryArcExternalIdentity.source == "comicvine", + StoryArcExternalIdentity.namespace == "story_arc", + ) + .execution_options(populate_existing=True) + ) + before = identity_state(owner) if owner is not None else None + cv_before = arc.comicvine_id + prior_event = await _latest_event(session, arc.id, "comicvine") + request = IdentityEventRequest( + uuid5(NAMESPACE_URL, f"pullbox:import-story-arc:{job.id}:{staged.id}"), + arc.id, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.STORY_ARC, external_id + ), + IdentityEvidenceKind.MYLAR_DATABASE + if staged.source_kind is StoryArcSourceKind.MYLAR3 + else IdentityEvidenceKind.MIGRATION, + ), + evidence_revision, + locator=IdentityEvidenceLocator( + IdentityEvidenceRecordKind.IMPORTED_STORY_ARC, staged.id + ), + ), + ) + receipt = ( + await attach_verified_identities(session, [request], require_current_ownership=True) + )[0] + await session.refresh(arc) + owner = await session.scalar( + select(StoryArcExternalIdentity) + .where( + StoryArcExternalIdentity.story_arc_id == arc.id, + StoryArcExternalIdentity.source == "comicvine", + StoryArcExternalIdentity.namespace == "story_arc", + ) + .execution_options(populate_existing=True) + ) + assert owner is not None + if not receipt.replayed and record_action is not None: + await record_action( + session, + job, + phase="story_arcs", + action_type=IDENTITY_VERIFIED_ACTION, + payload={ + "schema_version": 1, + "story_arc_id": arc.id, + "imported_story_arc_id": staged.id, + "external_identity_id": owner.id, + "identity_event_id": receipt.event_id, + "previous_event_id": prior_event.id if prior_event else None, + "restore_before": before, + "expected_after": identity_state(owner), + "comicvine_id_before": cv_before, + "comicvine_id_after": arc.comicvine_id, + }, + ) + return "created" if before is None else "reused" + + +def _rollback_request( + action: ImportJobAction, restored_revision: int | None +) -> tuple[str, str, str]: + payload = action.payload + expected = payload["expected_after"] + prepared = prepare_identity_event( + IdentityEventRequest( + uuid5( + NAMESPACE_URL, + f"pullbox:undo-arc-identity:{action.id}:{payload['identity_event_id']}", + ), + payload["story_arc_id"], + IdentityVerificationAction.OBSERVE, + IdentityEventEvidence( + ExactIdentityEvidence( + ExternalIdentityRef( + IdentityNamespace(expected["source"]), + MetadataEntityKind.STORY_ARC, + expected["external_id"], + ), + IdentityEvidenceKind.MIGRATION, + ), + _digest(payload), + locator=IdentityEvidenceLocator( + IdentityEvidenceRecordKind.IMPORT_JOB_ACTION, action.id + ), + ), + actor=IdentityEventActor.MIGRATION, + ) + ) + # Rollback observes the restored state; it is not a new verification or a + # rejection. This adapter extension records the revision without rewinding it. + request = json.loads(prepared.request_json) + request["import_rollback"] = { + "schema_version": 1, + "restored_revision": restored_revision, + "restored_event_id": payload["previous_event_id"], + } + return prepared.event_key, _digest(request), _json(request) + + +async def _rollback_chain_revision( + session: AsyncSession, action: ImportJobAction, latest: StoryArcIdentityEvent | None +) -> int | None: + """Recognize only a later undo from this job which restored this exact proof.""" + if latest is None: + return None + request = json.loads(latest.request_json) + origin = request.get("origin", {}) + restored = request.get("import_rollback", {}) + if ( + origin.get("record_kind") != "import_job_action" + or restored.get("restored_event_id") != action.payload["identity_event_id"] + or type(restored.get("restored_revision")) is not int + ): + return None + previous = await session.get(ImportJobAction, origin.get("record_id")) + if ( + previous is None + or previous.action_type != IDENTITY_VERIFIED_ACTION + or previous.import_job_id != action.import_job_id + or previous.sequence_no <= action.sequence_no + or previous.payload["story_arc_id"] != action.payload["story_arc_id"] + or latest.identity_namespace != action.payload["expected_after"]["source"] + or latest.external_id != action.payload["expected_after"]["external_id"] + or latest.verification_state != action.payload["expected_after"]["verification_state"] + ): + return None + revision = int(restored["restored_revision"]) + if (latest.event_key, latest.request_fingerprint, latest.request_json) != _rollback_request( + previous, revision + ): + return None + return revision + + +async def rollback_import_arc_identity(session: AsyncSession, action: ImportJobAction) -> None: + """Reverse only the persisted action's still-owned mutation; never erase history. + + The journal dispatcher validates the saved payload and staging/job ownership. + Parent locking serializes this path with attachment/catalog mutations. + """ + payload = action.payload + if session.get_bind().dialect.name == "sqlite": + await session.execute(update(StoryArc).where(false()).values(revision=StoryArc.revision)) + arc = await session.scalar( + select(StoryArc) + .where(StoryArc.id == payload["story_arc_id"]) + .with_for_update() + .execution_options(populate_existing=True) + ) + if arc is None: + return + event_key, _, _ = _rollback_request(action, None) + previous_undo = await session.scalar( + select(StoryArcIdentityEvent).where( + StoryArcIdentityEvent.story_arc_id == arc.id, + StoryArcIdentityEvent.event_key == event_key, + ) + ) + if previous_undo is not None: + revision = ( + json.loads(previous_undo.request_json) + .get("import_rollback", {}) + .get("restored_revision") + ) + if ( + previous_undo.event_key, + previous_undo.request_fingerprint, + previous_undo.request_json, + ) != _rollback_request(action, revision): + raise ValueError("Story-arc identity rollback receipt changed after import") + return + owner = await session.scalar( + select(StoryArcExternalIdentity) + .where(StoryArcExternalIdentity.id == payload["external_identity_id"]) + .execution_options(populate_existing=True) + ) + expected = dict(payload["expected_after"]) + original = await session.get( + StoryArcIdentityEvent, payload["identity_event_id"], populate_existing=True + ) + if ( + original is None + or original.story_arc_id != arc.id + or original.identity_namespace != expected["source"] + or original.external_id != expected["external_id"] + or original.verification_state is not IdentityVerificationState.VERIFIED + or original.request_json != expected["evidence_locator"] + or original.request_fingerprint + != hashlib.sha256(original.request_json.encode("ascii")).hexdigest() + ): + raise ValueError("Story-arc identity history changed after import; rollback refused") + latest = await _latest_event(session, arc.id, expected["source"]) + if latest is None or latest.id != payload["identity_event_id"]: + restored_revision = await _rollback_chain_revision(session, action, latest) + if restored_revision is None: + raise ValueError("Story-arc identity changed after import; rollback refused") + expected["revision"] = restored_revision + if ( + owner is None + or identity_state(owner) != expected + or arc.comicvine_id != payload["comicvine_id_after"] + ): + raise ValueError("Story-arc identity changed after import; rollback refused") + before = payload["restore_before"] + revision = owner.revision + 1 if before is not None else None + state = IdentityVerificationState.OBSERVED + if before is None: + await session.delete(owner) + else: + for field in ("verification_state", "evidence_kind", "evidence_locator"): + setattr(owner, field, before[field]) + for field in ("verified_at", "last_seen_at"): + setattr(owner, field, datetime.fromisoformat(before[field]) if before[field] else None) + assert revision is not None + owner.revision = revision + state = ( + IdentityVerificationState(before["verification_state"]) + if before["verification_state"] + else state + ) + arc.comicvine_id = payload["comicvine_id_before"] + key, fingerprint, request_json = _rollback_request(action, revision) + session.add( + StoryArcIdentityEvent( + story_arc_id=arc.id, + identity_namespace=IdentityNamespace(expected["source"]), + external_id=expected["external_id"], + verification_state=state, + evidence_kind=IdentityEvidenceKind.MIGRATION, + event_key=key, + request_fingerprint=fingerprint, + request_json=request_json, + ) + ) + await session.flush() + + +async def require_owned_arc_identity_history( + session: AsyncSession, *, arc_id: int, import_job_id: int +) -> None: + """Deleting an import-created parent must not cascade another writer's evidence.""" + actions = list( + await session.scalars( + select(ImportJobAction).where( + ImportJobAction.import_job_id == import_job_id, + ImportJobAction.action_type == IDENTITY_VERIFIED_ACTION, + ImportJobAction.payload["story_arc_id"].as_integer() == arc_id, + ) + ) + ) + by_event = {action.payload["identity_event_id"]: action for action in actions} + by_undo = {_rollback_request(action, None)[0]: action for action in actions} + result = await session.stream_scalars( + select(StoryArcIdentityEvent) + .where(StoryArcIdentityEvent.story_arc_id == arc_id) + .execution_options(yield_per=200, populate_existing=True) + ) + try: + async for event in result: + original = by_event.get(event.id) + if original is not None: + expected = original.payload["expected_after"] + if ( + event.identity_namespace == expected["source"] + and event.external_id == expected["external_id"] + and event.verification_state is IdentityVerificationState.VERIFIED + and event.request_json == expected["evidence_locator"] + and event.request_fingerprint + == hashlib.sha256(event.request_json.encode("ascii")).hexdigest() + ): + continue + undone = by_undo.get(event.event_key) + if undone is not None: + revision = ( + json.loads(event.request_json) + .get("import_rollback", {}) + .get("restored_revision") + ) + if ( + event.event_key, + event.request_fingerprint, + event.request_json, + ) == _rollback_request(undone, revision): + continue + raise ValueError("Story arc has later identity history; rollback refused") + finally: + await result.close() diff --git a/src/pullbox/services/import_story_arc_materialization.py b/src/pullbox/services/import_story_arc_materialization.py index 139fc0ca..7ac43831 100644 --- a/src/pullbox/services/import_story_arc_materialization.py +++ b/src/pullbox/services/import_story_arc_materialization.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json import os import stat from collections.abc import Awaitable, Callable, Mapping, Sequence @@ -10,11 +12,15 @@ from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING, Literal, cast -from sqlalchemy import func, or_, select, tuple_ +import structlog +from sqlalchemy import false, func, inspect, or_, select, tuple_, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id from pullbox.core.issue_numbers import normalize_issue_number_text +from pullbox.core.metadata_identity_events import IdentityEventReplayConflictError +from pullbox.core.metadata_identity_state import IdentityReviewRequiredError from pullbox.core.story_arc_naming import ( validate_story_arc_file_template, validate_story_arc_folder_template, @@ -41,6 +47,8 @@ StoryArcSourceKind, ) from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry +from pullbox.services.import_story_arc_identity import attach_import_arc_identity +from pullbox.services.metadata_identity_attachment import IdentityAttachmentConflictError from pullbox.services.story_arc_service import StoryArcService, StoryArcServiceError from pullbox.services.story_arc_sync_queue import ( MAX_IMPORT_STORY_ARC_SYNC_ENQUEUE_BATCH_SIZE, @@ -59,6 +67,7 @@ CancellationCheck = Callable[[], Awaitable[None]] +logger = structlog.get_logger(__name__) DurableCheckpoint = Callable[[], Awaitable[None]] CountField = Literal[ "arcs_created", @@ -215,7 +224,7 @@ class _MaterializationBatch: library_root_ids: set[int] identity_evidence: dict[ int, - tuple[list[tuple[str, str, str]], str | None], + tuple[list[tuple[str, str, str]], str | None, str], ] @@ -463,14 +472,25 @@ async def _load_batch_external_identity_evidence( staged_arcs: Sequence[ImportedStoryArc], entry_page_size: int, cancellation_check: CancellationCheck | None, -) -> dict[int, tuple[list[tuple[str, str, str]], str | None]]: +) -> dict[int, tuple[list[tuple[str, str, str]], str | None, str]]: """Scan provider-neutral entry evidence without materializing entry ORM rows.""" staged_arc_ids = [int(staged_arc.id) for staged_arc in staged_arcs] comicvine_ids: dict[int, set[str]] = {arc_id: set() for arc_id in staged_arc_ids} + revisions = { + int(arc.id): hashlib.sha256( + json.dumps( + [arc.source_kind.value, arc.source_key, arc.source_arc_id], + ensure_ascii=True, + separators=(",", ":"), + ).encode("ascii") + ) + for arc in staged_arcs + } invalid_arc_ids: set[int] = set() row_count = 0 result = await session.stream( select( + ImportedStoryArcEntry.id, ImportedStoryArcEntry.imported_story_arc_id, ImportedStoryArcEntry.evidence, ) @@ -482,7 +502,12 @@ async def _load_batch_external_identity_evidence( .execution_options(yield_per=entry_page_size) ) try: - async for imported_story_arc_id, evidence in result: + async for entry_id, imported_story_arc_id, evidence in result: + revisions[int(imported_story_arc_id)].update( + json.dumps( + [entry_id, evidence], sort_keys=True, ensure_ascii=True, separators=(",", ":") + ).encode("ascii") + ) values = comicvine_ids[int(imported_story_arc_id)] cv_arc_id = _mapping(evidence).get("cv_arc_id") if cv_arc_id is not None: @@ -499,7 +524,7 @@ async def _load_batch_external_identity_evidence( finally: await result.close() - loaded: dict[int, tuple[list[tuple[str, str, str]], str | None]] = {} + loaded: dict[int, tuple[list[tuple[str, str, str]], str | None, str]] = {} for staged_arc in staged_arcs: identities: list[tuple[str, str, str]] = [] if staged_arc.source_arc_id: @@ -512,7 +537,11 @@ async def _load_batch_external_identity_evidence( identities.append(("comicvine", "story_arc", next(iter(values)))) elif len(values) > 1: warning_code = "conflicting_external_identity_evidence" - loaded[int(staged_arc.id)] = (identities, warning_code) + loaded[int(staged_arc.id)] = ( + identities, + warning_code, + revisions[int(staged_arc.id)].hexdigest(), + ) return loaded @@ -524,7 +553,7 @@ async def _prepare_materialization_batch( state: _MaterializationState, identity_evidence: dict[ int, - tuple[list[tuple[str, str, str]], str | None], + tuple[list[tuple[str, str, str]], str | None, str], ], ) -> _MaterializationBatch: """Prefetch all canonical lookups needed by one bounded staging page.""" @@ -534,7 +563,7 @@ async def _prepare_materialization_batch( library_root_ids: set[int] = set() for staged_arc in staged_arcs: - identities, _warning = identity_evidence[int(staged_arc.id)] + identities, _warning, _revision = identity_evidence[int(staged_arc.id)] identity_keys.update(identities) if staged_arc.materialized_story_arc_id is not None: target_arc_ids.add(int(staged_arc.materialized_story_arc_id)) @@ -590,7 +619,15 @@ async def _prepare_materialization_batch( missing_arc_ids = target_arc_ids - state.arcs_by_id.keys() if missing_arc_ids: arcs = list( - (await session.scalars(select(StoryArc).where(StoryArc.id.in_(missing_arc_ids)))).all() + ( + await session.scalars( + select(StoryArc) + .where(StoryArc.id.in_(missing_arc_ids)) + .order_by(StoryArc.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ).all() ) for arc in arcs: state.arcs_by_id[int(arc.id)] = arc @@ -629,6 +666,91 @@ async def _materialize_one_arc( warnings: list[StoryArcMaterializationWarning], record_action: RecordActionFunc | None, batch: _MaterializationBatch, +) -> _ArcMaterializationContext | None: + """Keep each arc, policy, identity, and journal mutation atomic on conflict.""" + state = batch.state + arc_id = int(staged_arc.id) + identities, _, _ = batch.identity_evidence[arc_id] + cached_arcs = dict(state.arcs_by_id) # Bounded to the current staging page. + cached_identities = {key: state.identities_by_key.get(key) for key in identities} + loaded_keys = {key for key in identities if key in state.loaded_identity_keys} + source_key = (staged_arc.source_kind, staged_arc.source_key) + recovered = state.job_arc_ids_by_import_identity.get(source_key) + trial_counts = _MutableCounts() + trial_warnings: list[StoryArcMaterializationWarning] = [] + if session.get_bind().dialect.name == "sqlite": + await session.execute(update(StoryArc).where(false()).values(revision=StoryArc.revision)) + try: + async with session.begin_nested(): + context = await _materialize_one_arc_in_transaction( + session, + import_job_id=import_job_id, + job=job, + staged_arc=staged_arc, + counts=trial_counts, + warnings=trial_warnings, + record_action=record_action, + batch=batch, + ) + for field, amount in _count_snapshot(trial_counts).items(): + setattr(counts, field, getattr(counts, field) + amount) + warnings.extend(trial_warnings) + return context + except ( + IdentityAttachmentConflictError, + IdentityReviewRequiredError, + IdentityEventReplayConflictError, + IntegrityError, + ) as exc: + # SAVEPOINT rollback expires touched ORM rows. Refresh only this bounded + # page, and restore only this arc's cache keys, not the whole job index. + state.arcs_by_id = cached_arcs + logger.warning( + "import_story_arc_identity_review_required", + import_job_id=import_job_id, + imported_story_arc_id=arc_id, + error_type=type(exc).__name__, + ) + for arc in cached_arcs.values(): + if inspect(arc).expired: + await session.refresh(arc) + for key, owner in cached_identities.items(): + if owner is None: + state.identities_by_key.pop(key, None) + else: + state.identities_by_key[key] = owner + if key not in loaded_keys: + state.loaded_identity_keys.discard(key) + if recovered is None: + state.job_arc_ids_by_import_identity.pop(source_key, None) + else: + state.job_arc_ids_by_import_identity[source_key] = recovered + await session.refresh(staged_arc) + if inspect(job).expired: + await session.refresh(job) + staged_arc.status = ImportedStoryArcStatus.FAILED + counts.arcs_failed += 1 + _warn(warnings, "external_identity_requires_explicit_merge_review", staged_arc) + _persist_arc_materialization_diagnostics( + staged_arc, + story_arc_id=None, + status="failed", + counts=_count_snapshot(_MutableCounts(arcs_failed=1)), + warning_codes=["external_identity_requires_explicit_merge_review"], + ) + return None + + +async def _materialize_one_arc_in_transaction( + session: AsyncSession, + *, + import_job_id: int, + job: ImportJob, + staged_arc: ImportedStoryArc, + counts: _MutableCounts, + warnings: list[StoryArcMaterializationWarning], + record_action: RecordActionFunc | None, + batch: _MaterializationBatch, ) -> _ArcMaterializationContext | None: arc_warning_start = len(warnings) arc_counts = _MutableCounts() @@ -636,7 +758,7 @@ async def _materialize_one_arc( if policy.warning_code is not None: _warn(warnings, policy.warning_code, staged_arc) - identities, identity_warning = batch.identity_evidence[int(staged_arc.id)] + identities, identity_warning, evidence_revision = batch.identity_evidence[int(staged_arc.id)] if identity_warning is not None: _warn(warnings, identity_warning, staged_arc) _increment_counts(counts, arc_counts, "arcs_failed") @@ -717,6 +839,26 @@ async def _materialize_one_arc( ) for identity in identities: + if identity[0] == "comicvine": + outcome = await attach_import_arc_identity( + session, + arc=arc, + staged=staged_arc, + external_id=identity[2], + evidence_revision=evidence_revision, + job=job, + record_action=record_action, + ) + batch.state.identities_by_key[identity] = int(arc.id) + batch.state.loaded_identity_keys.add(identity) + _increment_counts( + counts, + arc_counts, + "external_identities_created" + if outcome == "created" + else "external_identities_reused", + ) + continue identity_outcome, materialized_identity = await _materialize_external_identity( session, arc=arc, @@ -742,7 +884,7 @@ async def _materialize_one_arc( elif identity_outcome == "reused": _increment_counts(counts, arc_counts, "external_identities_reused") else: - _warn(warnings, "external_identity_conflict", staged_arc) + raise IdentityAttachmentConflictError("Scoped import identity changed owner") return _ArcMaterializationContext( staged_arc=staged_arc, diff --git a/tests/integration/metadata_identity/test_arc_import_history.py b/tests/integration/metadata_identity/test_arc_import_history.py new file mode 100644 index 00000000..92bfead2 --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_import_history.py @@ -0,0 +1,539 @@ +"""Import identity history and rollback protect subsequent metadata decisions.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import event, func, select + +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import StoryArc, StoryArcExternalIdentity +from pullbox.models.import_job import ImportJobAction +from pullbox.models.metadata_identity import StoryArcIdentityEvent +from pullbox.models.story_arc import ImportedStoryArcStatus, StoryArcSourceKind +from pullbox.services.import_job_actions import record_action, rollback_action +from pullbox.services.import_story_arc_materialization import materialize_confirmed_story_arcs +from pullbox.services.metadata_identity_attachment import attach_verified_identities +from tests.integration.metadata_identity.test_arc_identity_attachment import _arc_request +from tests.unit.test_import_story_arc_materialization import ( + _add_job, + _add_staged_arc, + _add_staged_entry, + _confirmed_policy, +) + + +async def _staging(session, *, source=StoryArcSourceKind.MYLAR3, target=None): + job = await _add_job(session) + staged = await _add_staged_arc( + session, + job=job, + name="Imported arc", + source_key="import:arc", + source_arc_id="local-arc", + source_kind=source, + proposed_story_arc_id=target, + policy=_confirmed_policy(source=source.value), + ) + entry = await _add_staged_entry( + session, + staged_arc=staged, + source_ordinal=1, + reading_order=1, + issue_number_text="50-x", + cv_arc_id="4045-12", + ) + return job, staged, entry + + +async def _run(session, job): + return await materialize_confirmed_story_arcs( + session, import_job_id=job.id, record_action=record_action, entry_checkpoint_size=1 + ) + + +async def _undo(session, action): + await rollback_action( + session, + action_id=action.id, + action_type=action.action_type, + payload=dict(action.payload), + delete_series=AsyncMock(side_effect=AssertionError("Unexpected series deletion")), + ) + await session.flush() + + +@pytest.mark.parametrize("source", [StoryArcSourceKind.MYLAR3, StoryArcSourceKind.FOLDER]) +async def test_import_verifies_canonical_identity_with_stable_local_history( + identity_probe_db, source +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, staged, entry = await _staging(session, source=source) + result = await _run(session, job) + assert result.arcs_created == 1 + arc = await session.get(StoryArc, staged.materialized_story_arc_id) + assert arc.comicvine_id == 12 + owners = list(await session.scalars(select(StoryArcExternalIdentity))) + canonical = next(row for row in owners if row.source == "comicvine") + scoped = next(row for row in owners if row.source == source.value) + assert canonical.verification_state == "verified" and canonical.revision == 1 + assert scoped.verification_state is None + history = await session.scalar(select(StoryArcIdentityEvent)) + request = json.loads(history.request_json) + assert request["origin"] == {"record_kind": "imported_story_arc", "record_id": staged.id} + assert request["evidence_kind"] == ( + "mylar_database" if source == StoryArcSourceKind.MYLAR3 else "migration" + ) + assert request["actor"] == "automation" and request["action"] == "verify" + assert entry.evidence["cv_arc_id"] == "4045-12" + first_actions = list(await session.scalars(select(ImportJobAction.id))) + staged.status = ImportedStoryArcStatus.CONFIRMED + result = await _run(session, job) + assert result.arcs_reused == 1 and result.arcs_failed == 0 + assert list(await session.scalars(select(ImportJobAction.id))) == first_actions + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 1 + assert canonical.revision == 1 + # A genuinely changed source snapshot creates an observation, not a retry. + entry.evidence = {**entry.evidence, "source_revision": "changed"} + staged.status = ImportedStoryArcStatus.CONFIRMED + await _run(session, job) + await session.refresh(canonical) + assert canonical.revision == 2 + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 2 + + +@pytest.mark.parametrize("obstruction", ["legacy_owner", "conflicted"]) +async def test_identity_failure_rolls_back_only_its_arc_and_continues( + identity_probe_db, obstruction +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + existing = StoryArc(name="Existing", comicvine_id=12) + session.add(existing) + await session.flush() + if obstruction == "conflicted": + await attach_verified_identities(session, [_arc_request(existing.id, "12")]) + owner = await session.scalar(select(StoryArcExternalIdentity)) + owner.verification_state = IdentityVerificationState.CONFLICTED + job, staged, _ = await _staging( + session, target=existing.id if obstruction == "conflicted" else None + ) + other = await _add_staged_arc( + session, job=job, name="Unrelated", source_key="other", source_arc_id="other" + ) + result = await _run(session, job) + assert result.arcs_failed == 1 and result.arcs_created == 1 + assert result.arcs_merged == 0 and result.external_identities_created == 1 + assert staged.status == ImportedStoryArcStatus.FAILED + assert staged.materialized_story_arc_id is None + assert other.status == ImportedStoryArcStatus.IMPORTED + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 2 + assert ( + await session.scalar( + select(StoryArcExternalIdentity.id).where( + StoryArcExternalIdentity.external_id == "local-arc" + ) + ) + is None + ) + actions = list(await session.scalars(select(ImportJobAction))) + assert all(row.payload.get("imported_story_arc_id") != staged.id for row in actions) + await session.refresh(existing) + assert existing.revision == 1 and not existing.monitored + + +@pytest.mark.parametrize("existing_identity", [False, True]) +async def test_import_rollback_restores_prior_ownership_and_retains_history( + identity_probe_db, existing_identity +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + arc = StoryArc(name="Existing") + session.add(arc) + await session.flush() + if existing_identity: + session.add( + StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id="12", + evidence={"keep": True}, + source_url="https://example.invalid/arc", + ) + ) + job, _staged, _ = await _staging(session, target=arc.id) + await _run(session, job) + history = await session.scalar(select(StoryArcIdentityEvent)) + assert history is not None + original_request = history.request_json + actions = list( + await session.scalars( + select(ImportJobAction).order_by(ImportJobAction.sequence_no.desc()) + ) + ) + for action in actions: + await _undo(session, action) + await session.refresh(arc) + assert arc.comicvine_id is None and not arc.monitored + assert arc.revision == 1 + canonical = await session.scalar( + select(StoryArcExternalIdentity).where(StoryArcExternalIdentity.source == "comicvine") + ) + if existing_identity: + assert ( + canonical.evidence == {"keep": True} + and canonical.source_url == "https://example.invalid/arc" + ) + assert canonical.verification_state is None and canonical.revision == 3 + else: + assert canonical is None + events = list( + await session.scalars(select(StoryArcIdentityEvent).order_by(StoryArcIdentityEvent.id)) + ) + assert len(events) == 2 and events[0].request_json == original_request + assert events[-1].verification_state == "observed" + assert json.loads(events[-1].request_json)["origin"]["record_kind"] == "import_job_action" + identity_action = next( + row for row in actions if row.action_type == "story_arc_identity_verified" + ) + # A repeated rollback never undoes a subsequent fresh provider observation. + await attach_verified_identities(session, [_arc_request(arc.id, "12")]) + await _undo(session, identity_action) + await session.refresh(arc) + assert arc.comicvine_id == 12 + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 3 + + +@pytest.mark.parametrize( + "change", ["observation", "revision", "state", "column", "other_claim", "history_drift"] +) +async def test_rollback_refuses_later_identity_changes(identity_probe_db, change): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, staged, _ = await _staging(session) + await _run(session, job) + action = await session.scalar( + select(ImportJobAction).where( + ImportJobAction.action_type == "story_arc_identity_verified" + ) + ) + assert action is not None + arc = await session.get(StoryArc, staged.materialized_story_arc_id) + owner = await session.scalar( + select(StoryArcExternalIdentity).where(StoryArcExternalIdentity.source == "comicvine") + ) + if change == "observation": + await attach_verified_identities(session, [_arc_request(arc.id, "12")]) + elif change == "revision": + owner.revision += 1 + elif change == "state": + owner.verification_state = IdentityVerificationState.STALE + elif change == "column": + arc.comicvine_id = 13 + elif change == "history_drift": + history = await session.scalar(select(StoryArcIdentityEvent)) + history.verification_state = IdentityVerificationState.REJECTED + else: + from pullbox.core.metadata_identity_events import prepare_identity_event + + request = _arc_request(arc.id, "13") + prepared = prepare_identity_event(request) + session.add( + StoryArcIdentityEvent( + story_arc_id=arc.id, + identity_namespace="comicvine", + external_id="13", + verification_state="conflicted", + evidence_kind="provider_result", + event_key=prepared.event_key, + request_fingerprint=prepared.request_fingerprint, + request_json=prepared.request_json, + ) + ) + await session.flush() + with pytest.raises(ValueError, match="changed after import"): + await _undo(session, action) + assert await session.get(StoryArcExternalIdentity, owner.id) is not None + + +async def test_created_arc_can_be_rolled_back_without_hidden_commits(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, _staged, _ = await _staging(session) + job_id = job.id + async with factory() as session: + from pullbox.models.import_job import ImportJob + + job = await session.get(ImportJob, job_id) + await _run(session, job) + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 1 + await session.rollback() + async with factory.begin() as session: + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 0 + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 0 + job = await session.get(ImportJob, job_id) + await _run(session, job) + actions = list( + await session.scalars( + select(ImportJobAction).order_by(ImportJobAction.sequence_no.desc()) + ) + ) + for action in actions: + await _undo(session, action) + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 0 + assert await session.scalar(select(func.count()).select_from(StoryArcExternalIdentity)) == 0 + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 0 + + +async def test_several_import_observations_undo_in_reverse_without_revision_rewind( + identity_probe_db, +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + arc = StoryArc(name="Existing") + session.add(arc) + await session.flush() + await attach_verified_identities(session, [_arc_request(arc.id, "12")]) + job, staged, entry = await _staging(session, target=arc.id) + for revision in range(3): + entry.evidence = {**entry.evidence, "scan_revision": revision} + staged.status = ImportedStoryArcStatus.CONFIRMED + await _run(session, job) + actions = list( + await session.scalars( + select(ImportJobAction) + .where(ImportJobAction.action_type == "story_arc_identity_verified") + .order_by(ImportJobAction.sequence_no.desc()) + ) + ) + assert len(actions) == 3 + for index, action in enumerate(actions, 5): + from pullbox.services.import_story_arc_identity import identity_state + + owner = await session.scalar( + select(StoryArcExternalIdentity) + .where(StoryArcExternalIdentity.source == "comicvine") + .execution_options(populate_existing=True) + ) + assert identity_state(owner) == { + **action.payload["expected_after"], + "revision": index - 1, + } + await _undo(session, action) + owner = await session.scalar( + select(StoryArcExternalIdentity).where( + StoryArcExternalIdentity.source == "comicvine" + ) + ) + assert owner.revision == index and owner.verification_state == "verified" + events = list( + await session.scalars(select(StoryArcIdentityEvent).order_by(StoryArcIdentityEvent.id)) + ) + assert len(events) == 7 + assert owner.evidence_locator == events[0].request_json + + +@pytest.mark.parametrize("changed", [False, True]) +async def test_legacy_identity_journal_cannot_erase_new_verification(identity_probe_db, changed): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + arc = StoryArc(name="Existing") + session.add(arc) + await session.flush() + job, staged, _ = await _staging(session, target=arc.id) + staged.materialized_story_arc_id = arc.id + owner = StoryArcExternalIdentity( + story_arc_id=arc.id, + source="comicvine", + namespace="story_arc", + external_id="12", + evidence={"legacy": True}, + ) + session.add(owner) + await session.flush() + legacy_state = { + "story_arc_id": arc.id, + "source": "comicvine", + "namespace": "story_arc", + "external_id": "12", + "source_url": None, + "evidence": {"legacy": True}, + } + await record_action( + session, + job, + phase="story_arcs", + action_type="story_arc_external_identity_created", + payload={ + "external_identity_id": owner.id, + "story_arc_id": arc.id, + "imported_story_arc_id": staged.id, + "expected_after": legacy_state, + }, + ) + action = await session.scalar(select(ImportJobAction)) + if changed: + await attach_verified_identities(session, [_arc_request(arc.id, "12")]) + # The old journal has no lifecycle fields, and the legacy JSON did not change. + assert owner.evidence == legacy_state["evidence"] + with pytest.raises(ValueError, match="changed after import"): + await _undo(session, action) + else: + await _undo(session, action) + assert await session.scalar(select(StoryArcExternalIdentity.id)) is None + + +async def test_import_rollback_rejects_modified_selected_action(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, _staged, _ = await _staging(session) + await _run(session, job) + action = await session.scalar( + select(ImportJobAction).where( + ImportJobAction.action_type == "story_arc_identity_verified" + ) + ) + with pytest.raises(ValueError, match="action changed"): + await rollback_action( + session, + action_id=action.id, + action_type=action.action_type, + payload={**action.payload, "comicvine_id_before": 999}, + delete_series=AsyncMock(), + ) + assert await session.scalar(select(StoryArc.comicvine_id)) == 12 + + +async def test_failure_after_identity_write_discards_history_and_cached_owner( + identity_probe_db, monkeypatch +): + from pullbox.services import import_story_arc_materialization as materializer + from pullbox.services.metadata_identity_attachment import IdentityAttachmentConflictError + + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, staged, _ = await _staging(session) + other = await _add_staged_arc( + session, job=job, name="Surviving arc", source_key="other", source_arc_id="other" + ) + await _add_staged_entry( + session, + staged_arc=other, + source_ordinal=1, + reading_order=1, + issue_number_text="1", + cv_arc_id="4045-12", + ) + original = materializer.attach_import_arc_identity + failed_id = staged.id + + async def fail_after_attach(*args, **kwargs): + result = await original(*args, **kwargs) + if kwargs["staged"].id == failed_id: + raise IdentityAttachmentConflictError("Interleaved conflict") + return result + + monkeypatch.setattr(materializer, "attach_import_arc_identity", fail_after_attach) + result = await _run(session, job) + assert result.arcs_created == result.arcs_failed == 1 + assert result.external_identities_created == 2 + assert staged.status == ImportedStoryArcStatus.FAILED + assert other.status == ImportedStoryArcStatus.IMPORTED + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 1 + assert await session.scalar(select(StoryArc.name)) == "Surviving arc" + actions = list(await session.scalars(select(ImportJobAction))) + assert all( + action.payload.get("imported_story_arc_id", other.id) == other.id for action in actions + ) + + +async def test_created_arc_rollback_preserves_later_unowned_provider_evidence(identity_probe_db): + from pullbox.core.metadata_identity import IdentityNamespace + from pullbox.core.metadata_identity_events import prepare_identity_event + + _, factory, _ = identity_probe_db + async with factory.begin() as session: + job, staged, _ = await _staging(session) + await _run(session, job) + arc_id = staged.materialized_story_arc_id + request = _arc_request(arc_id, "44", IdentityNamespace.METRON) + prepared = prepare_identity_event(request) + # An observation without active ownership must not be lost when the + # importer undoes only its own ComicVine attachment. + session.add( + StoryArcIdentityEvent( + story_arc_id=arc_id, + identity_namespace="metron", + external_id="44", + verification_state="observed", + evidence_kind="provider_result", + event_key=prepared.event_key, + request_fingerprint=prepared.request_fingerprint, + request_json=prepared.request_json, + ) + ) + actions = list( + await session.scalars( + select(ImportJobAction).order_by(ImportJobAction.sequence_no.desc()) + ) + ) + for action in actions: + if action.action_type == "story_arc_created": + with pytest.raises(ValueError, match="identity history"): + await _undo(session, action) + else: + await _undo(session, action) + assert await session.get(StoryArc, arc_id) is not None + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 3 + + +async def test_rollback_waits_for_concurrent_provider_writer_and_rechecks(identity_probe_db): + engine, factory, _ = identity_probe_db + if engine.dialect.name != "postgresql": + pytest.skip("PostgreSQL row-lock behavior; SQLite has a serialized writer") + async with factory.begin() as session: + job, staged, _ = await _staging(session) + await _run(session, job) + arc_id = staged.materialized_story_arc_id + action_id = await session.scalar( + select(ImportJobAction.id).where( + ImportJobAction.action_type == "story_arc_identity_verified" + ) + ) + attempted_lock = asyncio.Event() + + def capture_lock(_conn, _cursor, statement, _params, _context, _many): + if "story_arcs" in statement and "FOR UPDATE" in statement: + attempted_lock.set() + + async def undo(): + async with factory.begin() as session: + action = await session.get(ImportJobAction, action_id) + with pytest.raises(ValueError, match="changed after import"): + await _undo(session, action) + + pending = None + try: + async with factory.begin() as writer: + await attach_verified_identities(writer, [_arc_request(arc_id, "12")]) + event.listen(engine.sync_engine, "before_cursor_execute", capture_lock) + pending = asyncio.create_task(undo()) + await asyncio.wait_for(attempted_lock.wait(), timeout=5) + await asyncio.sleep(0.05) + assert not pending.done() + await asyncio.wait_for(pending, timeout=10) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", capture_lock) + if pending is not None and not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + async with factory() as session: + assert ( + await session.scalar(select(StoryArc.comicvine_id).where(StoryArc.id == arc_id)) == 12 + ) + assert await session.scalar(select(func.count()).select_from(StoryArcIdentityEvent)) == 2 diff --git a/tests/unit/test_import_story_arc_materialization.py b/tests/unit/test_import_story_arc_materialization.py index 57334bd5..6fd77246 100644 --- a/tests/unit/test_import_story_arc_materialization.py +++ b/tests/unit/test_import_story_arc_materialization.py @@ -1655,7 +1655,7 @@ async def test_actual_mutations_are_journaled_once_in_reverse_safe_order( assert [action.action_type for action in first_actions] == [ "story_arc_created", "story_arc_external_identity_created", - "story_arc_external_identity_created", + "story_arc_identity_verified", "story_arc_membership_created", ] assert [action.id for action in second_actions] == [action.id for action in first_actions] diff --git a/tests/unit/test_utc_datetime.py b/tests/unit/test_utc_datetime.py new file mode 100644 index 00000000..71f5cb59 --- /dev/null +++ b/tests/unit/test_utc_datetime.py @@ -0,0 +1,34 @@ +"""UTC timestamps retain their instant at each database driver's boundary.""" + +from datetime import UTC, datetime, timedelta, timezone + +import pytest +from sqlalchemy.dialects import postgresql, sqlite + +from pullbox.models.base import UTCDateTime + + +@pytest.mark.parametrize( + "dialect", [sqlite.dialect(), postgresql.dialect()], ids=["sqlite", "postgresql"] +) +@pytest.mark.parametrize( + "value", + [ + None, + datetime(2026, 9, 28, 7, 30), + datetime(2026, 9, 28, 7, 30, tzinfo=UTC), + datetime(2026, 9, 28, 0, 30, tzinfo=timezone(timedelta(hours=-7))), + ], +) +def test_bind_normalizes_to_utc_without_losing_postgresql_timezone(dialect, value): + expected = None if value is None else datetime(2026, 9, 28, 7, 30, tzinfo=UTC) + if expected is not None and dialect.name == "sqlite": + expected = expected.replace(tzinfo=None) + assert UTCDateTime().process_bind_param(value, dialect) == expected + + +def test_results_normalize_aware_non_utc_values(): + value = datetime(2026, 9, 28, 0, 30, tzinfo=timezone(timedelta(hours=-7))) + result = UTCDateTime().process_result_value(value, postgresql.dialect()) + assert result == datetime(2026, 9, 28, 7, 30, tzinfo=UTC) + assert result.tzinfo is UTC From 0c43d6ca3fa1c9bd83e7cfd13336d7fc4f9a910a Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 01:34:23 -0700 Subject: [PATCH 17/97] feat: add revision-checked metadata identity review --- docs/development/DATABASE_STANDARDS.md | 21 + src/pullbox/api/v1/metadata_identities.py | 105 +++ src/pullbox/api/v1/router.py | 2 + src/pullbox/core/metadata_identity_events.py | 3 + .../schemas/metadata_identity_review.py | 37 ++ .../services/metadata_identity_review.py | 624 ++++++++++++++++++ .../api/test_metadata_identity_review_api.py | 134 ++++ .../metadata_identity/test_identity_review.py | 463 +++++++++++++ 8 files changed, 1389 insertions(+) create mode 100644 src/pullbox/api/v1/metadata_identities.py create mode 100644 src/pullbox/schemas/metadata_identity_review.py create mode 100644 src/pullbox/services/metadata_identity_review.py create mode 100644 tests/api/test_metadata_identity_review_api.py create mode 100644 tests/integration/metadata_identity/test_identity_review.py diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index ca5091e2..7210bd5f 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -370,6 +370,27 @@ PostgreSQL where the code already supports it. ## 4. Schema Conventions +### Metadata Identity Review + +Canonical series, issue, and Story Arc provider identities retain append-only +evidence separately from active ownership. Automatic writers use the shared +attachment service; non-verifying observations cannot acquire ownership or clear +a rejected/conflicted decision. Stale and conflicted owners retain uniqueness. + +The interactive `/api/v1/metadata-identities/{kind}/{local_id}` endpoints list +bounded saved claims, preview one saved event, and confirm/reject that event. +Writes require operator authentication, shared CSRF protection, and a current +review fingerprint/revision. The server supplies the actor; clients cannot +supply a provider identity or actor ID in a decision body. A replay receipt +proves historical completion, not current ownership. Replacing an assignment +requires explicit rejection first, and dependent issue identities must be +reviewed before detaching a series. Files and archive metadata are not changed +by identity review. + +Review, observation, and attachment share parent lock ordering and caller-owned +transactions. Compatibility ComicVine columns and retained history change +atomically with ownership. Do not bypass these services with ad hoc ID updates. + ### 4.1 Base Model Mixins **Current Pullbox implementation** diff --git a/src/pullbox/api/v1/metadata_identities.py b/src/pullbox/api/v1/metadata_identities.py new file mode 100644 index 00000000..b3d89e2a --- /dev/null +++ b/src/pullbox/api/v1/metadata_identities.py @@ -0,0 +1,105 @@ +"""Interactive review of saved identity claims; no provider or file mutation.""" + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Query + +from pullbox.api.deps import DbSession, InteractiveOperatorUser +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.metadata_identity_events import IdentityEventReplayConflictError +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + IdentityVerificationAction, +) +from pullbox.schemas.metadata_identity_review import ( + IdentityClaimRead, + IdentityReviewRead, + IdentityReviewReceiptRead, + IdentityReviewWrite, +) +from pullbox.schemas.pagination import PaginatedResponse +from pullbox.services.metadata_identity_attachment import IdentityAttachmentConflictError +from pullbox.services.metadata_identity_review import ( + IdentityReviewNotFoundError, + apply_identity_review, + list_identity_claims, + preview_identity_review, +) + +router = APIRouter(prefix="/metadata-identities", tags=["metadata-identities"]) +LocalId = Annotated[int, Path(gt=0, lt=2**63)] + + +def _error(exc: ValueError) -> HTTPException: + if isinstance(exc, IdentityReviewNotFoundError): + return HTTPException(404, str(exc)) + return HTTPException(409, str(exc)) + + +@router.get("/{kind}/{local_id}/claims", response_model=PaginatedResponse[IdentityClaimRead]) +async def identity_claims( + kind: MetadataEntityKind, + local_id: LocalId, + session: DbSession, + _user: InteractiveOperatorUser, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> PaginatedResponse[IdentityClaimRead]: + try: + items, total = await list_identity_claims( + session, kind, local_id, limit=limit, offset=offset + ) + except IdentityAttachmentConflictError as exc: + raise _error(exc) from exc + return PaginatedResponse( + items=[IdentityClaimRead.model_validate(item) for item in items], + total=total, + limit=limit, + offset=offset, + has_more=offset + len(items) < total, + ) + + +@router.get("/{kind}/{local_id}/review/{event_id}", response_model=IdentityReviewRead) +async def identity_review_preview( + kind: MetadataEntityKind, + local_id: LocalId, + event_id: LocalId, + session: DbSession, + _user: InteractiveOperatorUser, +) -> IdentityReviewRead: + try: + result = await preview_identity_review(session, kind, local_id, event_id) + except (IdentityReviewNotFoundError, IdentityAttachmentConflictError) as exc: + raise _error(exc) from exc + return IdentityReviewRead.model_validate(result) + + +@router.post("/{kind}/{local_id}/review/{event_id}", response_model=IdentityReviewReceiptRead) +async def identity_review_apply( + kind: MetadataEntityKind, + local_id: LocalId, + event_id: LocalId, + body: IdentityReviewWrite, + session: DbSession, + user: InteractiveOperatorUser, +) -> IdentityReviewReceiptRead: + try: + receipt = await apply_identity_review( + session, + kind, + local_id, + event_id, + action=IdentityVerificationAction(body.action), + fingerprint=body.fingerprint, + review_revision=body.review_revision, + actor_user_id=user.id, + ) + except ( + IdentityReviewNotFoundError, + IdentityAttachmentConflictError, + IdentityReviewRequiredError, + IdentityEventReplayConflictError, + ) as exc: + raise _error(exc) from exc + return IdentityReviewReceiptRead(event_id=receipt.event_id, replayed=receipt.replayed) diff --git a/src/pullbox/api/v1/router.py b/src/pullbox/api/v1/router.py index 4ec73d50..08ce2571 100644 --- a/src/pullbox/api/v1/router.py +++ b/src/pullbox/api/v1/router.py @@ -24,6 +24,7 @@ from pullbox.api.v1.intervention import router as intervention_router from pullbox.api.v1.issues import router as issues_router from pullbox.api.v1.library import router as library_router +from pullbox.api.v1.metadata_identities import router as metadata_identities_router from pullbox.api.v1.reader import router as reader_router from pullbox.api.v1.search import router as search_router from pullbox.api.v1.series import router as series_router @@ -48,6 +49,7 @@ v1_router.include_router(story_arc_placements_router) v1_router.include_router(issues_router) v1_router.include_router(library_router) +v1_router.include_router(metadata_identities_router) v1_router.include_router(reader_router) v1_router.include_router(downloads_router) v1_router.include_router(direct_providers_router) diff --git a/src/pullbox/core/metadata_identity_events.py b/src/pullbox/core/metadata_identity_events.py index f5519102..2739b73b 100644 --- a/src/pullbox/core/metadata_identity_events.py +++ b/src/pullbox/core/metadata_identity_events.py @@ -22,6 +22,9 @@ class IdentityEvidenceRecordKind(enum.StrEnum): ISSUE = "issue" STORY_ARC = "story_arc" STORY_ARC_IDENTITY = "story_arc_identity" + SERIES_IDENTITY_EVENT = "series_identity_event" + ISSUE_IDENTITY_EVENT = "issue_identity_event" + STORY_ARC_IDENTITY_EVENT = "story_arc_identity_event" IMPORTED_SERIES = "imported_series" IMPORTED_FILE = "imported_file" IMPORTED_STORY_ARC = "imported_story_arc" diff --git a/src/pullbox/schemas/metadata_identity_review.py b/src/pullbox/schemas/metadata_identity_review.py new file mode 100644 index 00000000..70b161cd --- /dev/null +++ b/src/pullbox/schemas/metadata_identity_review.py @@ -0,0 +1,37 @@ +"""Sanitized saved-claim review contracts.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind +from pullbox.core.metadata_identity_state import IdentityVerificationState + + +class IdentityClaimRead(BaseModel): + event_id: int + identity_namespace: IdentityNamespace + external_id: str + verification_state: IdentityVerificationState + + +class IdentityReviewRead(IdentityClaimRead): + entity_kind: MetadataEntityKind + local_id: int + current_external_id: str | None + owner_local_id: int | None + review_revision: int + fingerprint: str + + +class IdentityReviewWrite(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["confirm", "reject"] + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + review_revision: int = Field(gt=0, lt=2**63, strict=True) + + +class IdentityReviewReceiptRead(BaseModel): + event_id: int + replayed: bool diff --git a/src/pullbox/services/metadata_identity_review.py b/src/pullbox/services/metadata_identity_review.py new file mode 100644 index 00000000..f9a75db6 --- /dev/null +++ b/src/pullbox/services/metadata_identity_review.py @@ -0,0 +1,624 @@ +"""Saved identity evidence and explicit operator review.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid5 + +from sqlalchemy import case, delete, false, func, insert, or_, select, true, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_events import ( + IdentityEventActor, + IdentityEventEvidence, + IdentityEventReplayConflictError, + IdentityEventRequest, + prepare_identity_event, + validate_identity_event_replay, +) +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, + transition_identity_state, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationAction as Action, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationState as State, +) +from pullbox.models import Base, Issue, Series, User +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + IdentityAttachmentReceipt, + _lock_parent_graph, + _locked_targets, + _validate_issue_parents, + _validate_legacy_owners, +) + +if TYPE_CHECKING: + from sqlalchemy import RowMapping, Table + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql.elements import ColumnElement + + +_REVIEW_NAMESPACE = UUID("0d7b2c0d-1515-43b2-a9dd-c9a6d516d60c") + + +class IdentityReviewNotFoundError(ValueError): + """The requested saved claim is not part of this local target.""" + + +def _tables(kind: MetadataEntityKind) -> tuple[Table, Table, str]: + return ( + Base.metadata.tables[f"{kind.value}_external_identities"], + Base.metadata.tables[f"{kind.value}_identity_events"], + f"{kind.value}_id", + ) + + +def _scope(active: Table, kind: MetadataEntityKind) -> ColumnElement[bool]: + return active.c.namespace == "story_arc" if kind is MetadataEntityKind.STORY_ARC else true() + + +def _namespace(active: Table, kind: MetadataEntityKind) -> ColumnElement[str]: + return active.c.source if kind is MetadataEntityKind.STORY_ARC else active.c.identity_namespace + + +async def _begin_write(session: AsyncSession) -> None: + if session.get_bind().dialect.name == "sqlite": + await session.execute( + update(Series).where(false()).values(comicvine_id=Series.comicvine_id) + ) + + +async def _replay( + session: AsyncSession, request: IdentityEventRequest +) -> IdentityAttachmentReceipt | None: + _, history, target_key = _tables(request.evidence.claim.identity.entity_kind) + payload = prepare_identity_event(request) + row = ( + ( + await session.execute( + select(history).where( + history.c[target_key] == request.local_id, + history.c.event_key == payload.event_key, + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + return None + validate_identity_event_replay( + payload, event_key=row.event_key, request_fingerprint=row.request_fingerprint + ) + if row.request_json != payload.request_json: + raise IdentityEventReplayConflictError("Stored identity request is inconsistent") + return IdentityAttachmentReceipt(row.id, True) + + +async def _append( + session: AsyncSession, request: IdentityEventRequest, state: State +) -> IdentityAttachmentReceipt: + identity = request.evidence.claim.identity + _, history, target_key = _tables(identity.entity_kind) + payload = prepare_identity_event(request) + event_id = await session.scalar( + insert(history) + .values( + **{ + target_key: request.local_id, + "identity_namespace": identity.namespace, + "external_id": identity.external_id, + "verification_state": state, + "evidence_kind": request.evidence.claim.evidence_kind, + "event_key": payload.event_key, + "request_fingerprint": payload.request_fingerprint, + "request_json": payload.request_json, + } + ) + .returning(history.c.id) + ) + assert event_id is not None + return IdentityAttachmentReceipt(event_id, False) + + +async def _owner( + session: AsyncSession, kind: MetadataEntityKind, local_id: int, namespace: IdentityNamespace +) -> RowMapping | None: + active, _, key = _tables(kind) + return ( + ( + await session.execute( + select(active).where( + active.c[key] == local_id, + _scope(active, kind), + _namespace(active, kind) == namespace, + ) + ) + ) + .mappings() + .one_or_none() + ) + + +async def record_identity_observation( + session: AsyncSession, request: IdentityEventRequest +) -> IdentityAttachmentReceipt: + """Record adapter evidence without acquiring ownership or clearing decisions. + + Adapters establish provenance before calling. This is not an HTTP boundary; + user confirmation and rejection are accepted only by the review service. + """ + if request.actor is not IdentityEventActor.AUTOMATION or request.action not in { + Action.OBSERVE, + Action.REPORT_CONFLICT, + Action.MARK_STALE, + }: + raise ValueError("Only automatic non-verifying observations are accepted") + identity = request.evidence.claim.identity + kind, namespace = identity.entity_kind, identity.namespace + active, history, key = _tables(kind) + await _begin_write(session) + async with session.begin_nested(): + await _lock_parent_graph(session, [request]) + await _locked_targets(session, kind, [request.local_id]) + replay = await _replay(session, request) + if replay: + return replay + owner = await _owner(session, kind, request.local_id, namespace) + latest = await session.scalar( + select(history.c.verification_state) + .where( + history.c[key] == request.local_id, + history.c.identity_namespace == namespace, + history.c.external_id == identity.external_id, + ) + .order_by(history.c.id.desc()) + .limit(1) + ) + same_owner = owner is not None and owner.external_id == identity.external_id + previous = latest or ( + owner.verification_state if owner is not None and same_owner else State.OBSERVED + ) + # Retained rejection always wins; an active conflict cannot be cleared by + # observing an older, previously verified claim again. + if previous is not State.REJECTED and same_owner and owner is not None: + previous = owner.verification_state + state = transition_identity_state(previous, request.action) + if owner is not None and ( + same_owner or (request.action is Action.REPORT_CONFLICT and state is not State.REJECTED) + ): + values: dict[str, object] = {"revision": owner.revision + 1} + if same_owner: + values["verification_state"] = state + if request.action is Action.OBSERVE: + values["last_seen_at"] = datetime.now(UTC) + else: + values["verification_state"] = State.CONFLICTED + await session.execute(update(active).where(active.c.id == owner.id).values(**values)) + return await _append(session, request, state) + + +async def _candidate( + session: AsyncSession, kind: MetadataEntityKind, local_id: int, event_id: int +) -> RowMapping: + _, history, key = _tables(kind) + row = ( + ( + await session.execute( + select(history).where( + history.c[key] == local_id, + history.c.id == event_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + raise IdentityReviewNotFoundError("Saved identity evidence was not found for this record") + return row + + +def _parent(row: RowMapping) -> ExternalIdentityRef | None: + """Read only bounded, canonical parent proof from saved internal evidence.""" + try: + if hashlib.sha256(row.request_json.encode()).hexdigest() != row.request_fingerprint: + raise ValueError + payload = json.loads(row.request_json) + if not isinstance(payload, dict): + raise ValueError + parent = payload.get("parent_identity") + if parent is None: + return None + if not isinstance(parent, dict): + raise ValueError + result = ExternalIdentityRef( + IdentityNamespace(parent["namespace"]), + MetadataEntityKind(parent["entity_kind"]), + parent["external_id"], + ) + if ( + result.entity_kind is not MetadataEntityKind.SERIES + or result.namespace != row.identity_namespace + ): + raise ValueError + return result + except (KeyError, TypeError, ValueError) as exc: + raise IdentityReviewRequiredError( + "Saved parent evidence is invalid; recheck its source" + ) from exc + + +def _request( + kind: MetadataEntityKind, + local_id: int, + row: RowMapping, + *, + action: Action, + fingerprint: str, + review_revision: int, + actor_user_id: int, +) -> IdentityEventRequest: + from pullbox.core.metadata_identity_events import ( + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + ) + + identity = ExternalIdentityRef(row.identity_namespace, kind, row.external_id) + try: + parent = _parent(row) + except IdentityReviewRequiredError: + if action is not Action.REJECT: + raise + # An invalid proof must remain rejectable without trusting its contents. + parent = None + return IdentityEventRequest( + uuid5( + _REVIEW_NAMESPACE, f"{kind}:{local_id}:{row.id}:{actor_user_id}:{action}:{fingerprint}" + ), + local_id, + action, + IdentityEventEvidence( + ExactIdentityEvidence(identity, IdentityEvidenceKind.USER_SELECTION), + fingerprint, + locator=IdentityEvidenceLocator( + IdentityEvidenceRecordKind(f"{kind.value}_identity_event"), row.id + ), + parent_identity=parent, + ), + actor=IdentityEventActor.USER, + actor_user_id=actor_user_id, + review_revision=review_revision, + ) + + +async def _preview( + session: AsyncSession, kind: MetadataEntityKind, local_id: int, row: RowMapping +) -> dict[str, object]: + active, history, key = _tables(kind) + target = (await _locked_targets(session, kind, [local_id]))[local_id] + owners = ( + ( + await session.execute( + select(active) + .where( + _scope(active, kind), + _namespace(active, kind) == row.identity_namespace, + or_(active.c[key] == local_id, active.c.external_id == row.external_id), + ) + .order_by(active.c.id) + ) + ) + .mappings() + .all() + ) + latest = await session.scalar( + select(func.max(history.c.id)).where( + history.c[key] == local_id, + history.c.identity_namespace == row.identity_namespace, + ) + ) + target_model = Base.metadata.tables[ + { + MetadataEntityKind.SERIES: "series", + MetadataEntityKind.ISSUE: "issues", + MetadataEntityKind.STORY_ARC: "story_arcs", + }[kind] + ] + legacy_owner = None + if row.identity_namespace is IdentityNamespace.COMICVINE and int(row.external_id) < 2**63: + legacy_owner = await session.scalar( + select(target_model.c.id).where(target_model.c.comicvine_id == int(row.external_id)) + ) + proof: dict[str, object] = { + "schema_version": 1, + "candidate": dict(row), + "target": dict(target), + "owners": [dict(owner) for owner in owners], + "latest_event": latest, + "legacy_owner": legacy_owner, + } + if kind is MetadataEntityKind.ISSUE: + series = ( + ( + await session.execute( + select(Series.id, Series.comicvine_id).where(Series.id == target.series_id) + ) + ) + .mappings() + .one() + ) + parent_owner = await _owner( + session, MetadataEntityKind.SERIES, target.series_id, row.identity_namespace + ) + proof["parent"] = dict(series) + proof["parent_owner"] = dict(parent_owner) if parent_owner else None + fingerprint = hashlib.sha256( + json.dumps(proof, sort_keys=True, default=str, separators=(",", ":")).encode() + ).hexdigest() + current = next((item for item in owners if item[key] == local_id), None) + external_owner = next( + (item[key] for item in owners if item.external_id == row.external_id), None + ) + state = await session.scalar( + select(history.c.verification_state) + .where( + history.c[key] == local_id, + history.c.identity_namespace == row.identity_namespace, + history.c.external_id == row.external_id, + ) + .order_by(history.c.id.desc()) + .limit(1) + ) + if ( + state is not State.REJECTED + and current is not None + and current.external_id == row.external_id + ): + state = current.verification_state + return { + "event_id": row.id, + "entity_kind": kind, + "local_id": local_id, + "identity_namespace": row.identity_namespace, + "external_id": row.external_id, + "verification_state": state, + "current_external_id": current.external_id if current else None, + "owner_local_id": external_owner or legacy_owner, + "review_revision": (latest or 0) + 1, + "fingerprint": fingerprint, + } + + +async def preview_identity_review( + session: AsyncSession, kind: MetadataEntityKind, local_id: int, event_id: int +) -> dict[str, object]: + """Return a sanitized optimistic snapshot; it is not an authorization token.""" + row = await _candidate(session, kind, local_id, event_id) + return await _preview(session, kind, local_id, row) + + +async def list_identity_claims( + session: AsyncSession, kind: MetadataEntityKind, local_id: int, *, limit: int, offset: int +) -> tuple[list[dict[str, object]], int]: + """Page the latest state per claim, without returning raw evidence or paths.""" + if not 1 <= limit <= 200 or offset < 0: + raise ValueError("Invalid identity claim page") + await _locked_targets(session, kind, [local_id]) + active, history, key = _tables(kind) + latest = ( + select(func.max(history.c.id).label("id")) + .where(history.c[key] == local_id) + .group_by(history.c.identity_namespace, history.c.external_id) + .subquery() + ) + total = await session.scalar(select(func.count()).select_from(latest)) + result = await session.execute( + select( + history.c.id.label("event_id"), + history.c.identity_namespace, + history.c.external_id, + case( + (history.c.verification_state == State.REJECTED, history.c.verification_state), + else_=func.coalesce(active.c.verification_state, history.c.verification_state), + ).label("verification_state"), + ) + .outerjoin( + active, + (active.c[key] == history.c[key]) + & _scope(active, kind) + & (_namespace(active, kind) == history.c.identity_namespace) + & (active.c.external_id == history.c.external_id), + ) + .where(history.c.id.in_(select(latest.c.id))) + .order_by(history.c.id.desc()) + .limit(limit) + .offset(offset) + ) + return [dict(row) for row in result.mappings()], total or 0 + + +async def apply_identity_review( + session: AsyncSession, + kind: MetadataEntityKind, + local_id: int, + event_id: int, + *, + action: Action, + fingerprint: str, + review_revision: int, + actor_user_id: int, +) -> IdentityAttachmentReceipt: + """Apply an authenticated decision in the caller's transaction, without I/O. + + The transport must require interactive operator auth and CSRF. Actor identity + comes from that dependency, never the request body. Replays return history, + not a claim that the old ownership is still current. + """ + if action not in {Action.CONFIRM, Action.REJECT}: + raise ValueError("Review requires an explicit confirm or reject decision") + await _begin_write(session) + try: + async with session.begin_nested(): + if not await session.scalar( + select(User.id).where(User.id == actor_user_id, User.is_active.is_(True)) + ): + raise IdentityReviewRequiredError("An active operator is required") + row = await _candidate(session, kind, local_id, event_id) + request = _request( + kind, + local_id, + row, + action=action, + fingerprint=fingerprint, + review_revision=review_revision, + actor_user_id=actor_user_id, + ) + await _lock_parent_graph(session, [request]) + replay = await _replay(session, request) + if replay: + return replay + current = await _preview(session, kind, local_id, row) + if ( + current["fingerprint"] != fingerprint + or current["review_revision"] != review_revision + ): + raise IdentityReviewRequiredError( + "Identity evidence changed; reload the review before deciding" + ) + if action is Action.CONFIRM: + await _confirm(session, request) + state = State.VERIFIED + else: + await _reject(session, request) + state = State.REJECTED + return await _append(session, request, state) + except IntegrityError as exc: + raise IdentityAttachmentConflictError( + "Identity ownership changed; reload before retrying" + ) from exc + + +async def _confirm(session: AsyncSession, request: IdentityEventRequest) -> None: + identity = request.evidence.claim.identity + kind, namespace = identity.entity_kind, identity.namespace + if namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63: + raise IdentityAttachmentConflictError( + "ComicVine identity exceeds the compatibility ID range" + ) + targets = await _locked_targets(session, kind, [request.local_id]) + await _validate_legacy_owners(session, kind, [request], targets) + if kind is MetadataEntityKind.ISSUE: + if request.evidence.parent_identity is None: + raise IdentityAttachmentConflictError( + "Issue confirmation requires exact series evidence; recheck its source" + ) + await _validate_issue_parents(session, [request], targets) + active, _, key = _tables(kind) + owner = await _owner(session, kind, request.local_id, namespace) + other = await session.scalar( + select(active.c[key]).where( + _scope(active, kind), + _namespace(active, kind) == namespace, + active.c.external_id == identity.external_id, + active.c[key] != request.local_id, + ) + ) + if other is not None or (owner is not None and owner.external_id != identity.external_id): + raise IdentityAttachmentConflictError( + "Identity already has an owner; review that assignment before replacing it" + ) + now = datetime.now(UTC) + values: dict[str, object] = { + "verification_state": State.VERIFIED, + "evidence_kind": IdentityEvidenceKind.USER_SELECTION, + "evidence_locator": prepare_identity_event(request).request_json, + "verified_at": now, + "last_seen_at": now, + "revision": owner.revision + 1 if owner else 1, + } + if owner: + await session.execute(update(active).where(active.c.id == owner.id).values(**values)) + else: + values.update({key: request.local_id, "external_id": identity.external_id}) + values.update( + {"source": namespace.value, "namespace": "story_arc"} + if kind is MetadataEntityKind.STORY_ARC + else {"identity_namespace": namespace} + ) + await session.execute(insert(active).values(**values)) + if namespace is IdentityNamespace.COMICVINE: + target = Base.metadata.tables[ + { + MetadataEntityKind.SERIES: "series", + MetadataEntityKind.ISSUE: "issues", + MetadataEntityKind.STORY_ARC: "story_arcs", + }[kind] + ] + await session.execute( + update(target) + .where(target.c.id == request.local_id) + .values(comicvine_id=int(identity.external_id)) + ) + + +async def _reject(session: AsyncSession, request: IdentityEventRequest) -> None: + identity = request.evidence.claim.identity + kind, namespace = identity.entity_kind, identity.namespace + active, _, _ = _tables(kind) + owner = await _owner(session, kind, request.local_id, namespace) + target = Base.metadata.tables[ + { + MetadataEntityKind.SERIES: "series", + MetadataEntityKind.ISSUE: "issues", + MetadataEntityKind.STORY_ARC: "story_arcs", + }[kind] + ] + legacy = await session.scalar( + select(target.c.comicvine_id).where(target.c.id == request.local_id) + ) + same_owner = owner is not None and owner.external_id == identity.external_id + same_legacy = namespace is IdentityNamespace.COMICVINE and str(legacy) == identity.external_id + if kind is MetadataEntityKind.SERIES and (same_owner or same_legacy): + child = Base.metadata.tables["issue_external_identities"] + dependent = await session.scalar( + select(child.c.id) + .join(Issue, Issue.id == child.c.issue_id) + .where( + Issue.series_id == request.local_id, + child.c.identity_namespace == namespace, + ) + .limit(1) + ) + legacy_child = namespace is IdentityNamespace.COMICVINE and await session.scalar( + select(Issue.id) + .where( + Issue.series_id == request.local_id, + Issue.comicvine_id.is_not(None), + ) + .limit(1) + ) + if dependent or legacy_child: + raise IdentityAttachmentConflictError( + "Review dependent issue identities before detaching their series" + ) + if same_owner and owner is not None: + await session.execute(delete(active).where(active.c.id == owner.id)) + if same_legacy: + await session.execute( + update(target).where(target.c.id == request.local_id).values(comicvine_id=None) + ) diff --git a/tests/api/test_metadata_identity_review_api.py b/tests/api/test_metadata_identity_review_api.py new file mode 100644 index 00000000..87ad00c6 --- /dev/null +++ b/tests/api/test_metadata_identity_review_api.py @@ -0,0 +1,134 @@ +"""Operator identity review uses real auth, CSRF, saved evidence and revisions.""" + +import os +import sys + +import pytest +from sqlalchemy import select + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models import Series +from pullbox.models.metadata_identity import SeriesIdentityEvent +from pullbox.services.auth_service import SESSION_COOKIE_NAME, AuthService +from pullbox.services.metadata_identity_review import record_identity_observation +from tests.integration.metadata_identity.test_identity_review import claim + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +pytest_plugins = ["conftest_security"] + + +@pytest.fixture +async def saved_claim(sec_db): + async with sec_db.begin() as session: + series = Series(title="Needs identity review", sort_title="needs identity review") + session.add(series) + await session.flush() + saved = await record_identity_observation( + session, claim(series.id, MetadataEntityKind.SERIES) + ) + return series.id, saved.event_id + + +def url(saved_claim): + local_id, event_id = saved_claim + return f"/api/v1/metadata-identities/series/{local_id}/review/{event_id}" + + +def csrf(client): + return { + "X-CSRF-Token": AuthService.get_csrf_token_from_session( + client.cookies.get(SESSION_COOKIE_NAME) + ) + } + + +async def test_review_roundtrip_uses_session_actor_and_safe_payload( + authenticated_client, sec_user, sec_db, saved_claim +): + preview = await authenticated_client.get(url(saved_claim)) + assert preview.status_code == 200 + data = preview.json() + assert data["external_id"] == "42" + assert "request_json" not in data and "evidence_locator" not in data + payload = { + "action": "confirm", + "fingerprint": data["fingerprint"], + "review_revision": data["review_revision"], + } + applied = await authenticated_client.post( + url(saved_claim), json=payload, headers=csrf(authenticated_client) + ) + assert applied.status_code == 200 + assert applied.json()["replayed"] is False + again = await authenticated_client.post( + url(saved_claim), json=payload, headers=csrf(authenticated_client) + ) + assert again.status_code == 200 and again.json()["replayed"] is True + async with sec_db() as session: + event = await session.scalar( + select(SeriesIdentityEvent).order_by(SeriesIdentityEvent.id.desc()) + ) + import json + + assert json.loads(event.request_json)["actor_user_id"] == sec_user.id + assert (await session.get(Series, saved_claim[0])).comicvine_id == 42 + + +async def test_review_requires_operator_session_and_csrf( + authenticated_client, unauthenticated_client, sec_api_key, saved_claim +): + preview = await authenticated_client.get(url(saved_claim)) + assert preview.status_code == 200 + data = preview.json() + payload = { + "action": "reject", + "fingerprint": data["fingerprint"], + "review_revision": data["review_revision"], + } + assert (await authenticated_client.post(url(saved_claim), json=payload)).status_code == 403 + assert (await unauthenticated_client.get(url(saved_claim))).status_code in {401, 403} + assert ( + await unauthenticated_client.post( + url(saved_claim), json=payload, headers={"X-API-Key": sec_api_key} + ) + ).status_code in {401, 403} + forged = await authenticated_client.post( + url(saved_claim), json={**payload, "actor_user_id": 999}, headers=csrf(authenticated_client) + ) + assert forged.status_code == 422 + + +async def test_review_rejects_mismatched_target_and_stale_revision( + authenticated_client, sec_db, saved_claim +): + preview = await authenticated_client.get(url(saved_claim)) + assert preview.status_code == 200 + data = preview.json() + async with sec_db.begin() as session: + await record_identity_observation(session, claim(saved_claim[0], MetadataEntityKind.SERIES)) + result = await authenticated_client.post( + url(saved_claim), + headers=csrf(authenticated_client), + json={ + "action": "confirm", + "fingerprint": data["fingerprint"], + "review_revision": data["review_revision"], + }, + ) + assert result.status_code == 409 + result = await authenticated_client.get( + f"/api/v1/metadata-identities/series/999/review/{saved_claim[1]}" + ) + assert result.status_code == 404 + + +async def test_claims_listing_is_bounded_and_does_not_leak_evidence( + authenticated_client, saved_claim +): + response = await authenticated_client.get( + f"/api/v1/metadata-identities/series/{saved_claim[0]}/claims?limit=1" + ) + assert response.status_code == 200 + assert response.json()["total"] == 1 + assert response.json()["items"][0]["event_id"] == saved_claim[1] + assert "request_json" not in response.text diff --git a/tests/integration/metadata_identity/test_identity_review.py b/tests/integration/metadata_identity/test_identity_review.py new file mode 100644 index 00000000..6b310bee --- /dev/null +++ b/tests/integration/metadata_identity/test_identity_review.py @@ -0,0 +1,463 @@ +"""Durable decisions, stale reviews, and ownership on both database backends.""" + +import asyncio +import json +from dataclasses import replace +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest +from pullbox.core.metadata_identity_state import ( + IdentityReviewRequiredError, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationAction as Action, +) +from pullbox.core.metadata_identity_state import ( + IdentityVerificationState as State, +) +from pullbox.models import Base, Issue, Series, StoryArc, User +from pullbox.services.metadata_identity_attachment import ( + IdentityAttachmentConflictError, + attach_verified_identities, +) +from pullbox.services.metadata_identity_review import ( + apply_identity_review, + list_identity_claims, + preview_identity_review, + record_identity_observation, +) + + +def claim(local_id, kind, external="42", action=Action.OBSERVE, parent=None): + identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, external) + return IdentityEventRequest( + uuid4(), + local_id, + action, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, MetadataSource.COMICVINE_API + ), + "a" * 64, + source_identity=identity, + parent_identity=parent, + ), + ) + + +async def seed(factory, kind): + async with factory.begin() as session: + user = User(username="identity-reviewer", password_hash="unused") + series = Series(title="Saved series", sort_title="saved series") + session.add_all([user, series]) + await session.flush() + target = series + parent = None + if kind is MetadataEntityKind.ISSUE: + await attach_verified_identities( + session, [claim(series.id, MetadataEntityKind.SERIES, "90", Action.VERIFY)] + ) + parent = ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "90" + ) + target = Issue(series_id=series.id, issue_number=13, issue_number_text="13A") + session.add(target) + elif kind is MetadataEntityKind.STORY_ARC: + target = StoryArc(name="Saved arc") + session.add(target) + await session.flush() + return target.id, user.id, parent + + +async def decide(session, kind, target, event_id, user_id, action=Action.CONFIRM, preview=None): + snapshot = preview or await preview_identity_review(session, kind, target, event_id) + return await apply_identity_review( + session, + kind, + target, + event_id, + action=action, + fingerprint=snapshot["fingerprint"], + review_revision=snapshot["review_revision"], + actor_user_id=user_id, + ) + + +async def rows(session, kind, suffix): + table = Base.metadata.tables[f"{kind.value}_{suffix}"] + return (await session.execute(select(table).order_by(table.c.id))).mappings().all() + + +async def test_observation_persists_once_without_claiming_ownership(identity_probe_db): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, _, _ = await seed(factory, kind) + request = claim(target, kind) + async with factory.begin() as session: + saved = await record_identity_observation(session, request) + history = await rows(session, kind, "identity_events") + assert len(history) == 1 + assert history[0].id == saved.event_id + assert history[0].verification_state == State.OBSERVED + assert not await rows(session, kind, "external_identities") + replay = await record_identity_observation(session, request) + assert replay.replayed and replay.event_id == saved.event_id + assert len(await rows(session, kind, "identity_events")) == 1 + + +@pytest.mark.parametrize("kind", list(MetadataEntityKind)) +async def test_review_confirm_reject_reobserve_and_reconfirm(identity_probe_db, kind): + _, factory, _ = identity_probe_db + target, user, parent = await seed(factory, kind) + request = claim(target, kind, parent=parent) + async with factory.begin() as session: + saved = await record_identity_observation(session, request) + assert not await rows(session, kind, "external_identities") + snapshot = await preview_identity_review(session, kind, target, saved.event_id) + confirmed = await decide(session, kind, target, saved.event_id, user, preview=snapshot) + replay = await decide(session, kind, target, saved.event_id, user, preview=snapshot) + assert replay.replayed and replay.event_id == confirmed.event_id + owner = (await rows(session, kind, "external_identities"))[0] + assert owner.external_id == "42" and owner.verification_state == State.VERIFIED + rejected = await decide(session, kind, target, confirmed.event_id, user, Action.REJECT) + assert not await rows(session, kind, "external_identities") + history = await rows(session, kind, "identity_events") + assert [row.verification_state for row in history] == [ + State.OBSERVED, + State.VERIFIED, + State.REJECTED, + ] + again = await record_identity_observation(session, replace(request, operation_id=uuid4())) + assert (await rows(session, kind, "identity_events"))[ + -1 + ].verification_state == State.REJECTED + with pytest.raises(IdentityReviewRequiredError): + await attach_verified_identities( + session, [replace(request, operation_id=uuid4(), action=Action.VERIFY)] + ) + await decide(session, kind, target, again.event_id, user) + assert (await rows(session, kind, "external_identities"))[0].external_id == "42" + assert rejected.event_id != confirmed.event_id + + +@pytest.mark.parametrize("kind", list(MetadataEntityKind)) +async def test_lifecycle_retains_owner_and_blocks_automatic_conflict_clear(identity_probe_db, kind): + _, factory, _ = identity_probe_db + target, user, parent = await seed(factory, kind) + request = claim(target, kind, action=Action.VERIFY, parent=parent) + async with factory.begin() as session: + original = (await attach_verified_identities(session, [request]))[0] + stale = replace(request, operation_id=uuid4(), action=Action.MARK_STALE) + await record_identity_observation(session, stale) + owner = (await rows(session, kind, "external_identities"))[0] + assert owner.verification_state == State.STALE and owner.revision == 2 + assert owner.last_seen_at == owner.verified_at + conflict = replace(request, operation_id=uuid4(), action=Action.REPORT_CONFLICT) + await record_identity_observation(session, conflict) + await record_identity_observation(session, replace(stale, operation_id=uuid4())) + owner = (await rows(session, kind, "external_identities"))[0] + assert owner.verification_state == State.CONFLICTED + with pytest.raises(IdentityReviewRequiredError): + await attach_verified_identities(session, [replace(request, operation_id=uuid4())]) + await decide(session, kind, target, original.event_id, user) + assert (await rows(session, kind, "external_identities"))[ + 0 + ].verification_state == State.VERIFIED + + +@pytest.mark.parametrize("drift", ["observation", "owner", "legacy"]) +async def test_stale_review_is_rejected_without_partial_writes(identity_probe_db, drift): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + request = claim(target, kind) + async with factory.begin() as session: + saved = await record_identity_observation(session, request) + snapshot = await preview_identity_review(session, kind, target, saved.event_id) + async with factory.begin() as session: + if drift == "observation": + await record_identity_observation(session, replace(request, operation_id=uuid4())) + elif drift == "owner": + await attach_verified_identities(session, [replace(request, action=Action.VERIFY)]) + else: + await session.execute(update(Series).where(Series.id == target).values(comicvine_id=42)) + async with factory.begin() as session: + before = await rows(session, kind, "identity_events") + with pytest.raises(IdentityReviewRequiredError): + await decide(session, kind, target, saved.event_id, user, preview=snapshot) + assert await rows(session, kind, "identity_events") == before + + +async def test_candidate_rejection_does_not_release_other_owned_identity(identity_probe_db): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + async with factory.begin() as session: + await attach_verified_identities(session, [claim(target, kind, action=Action.VERIFY)]) + other = await record_identity_observation( + session, claim(target, kind, "99", Action.REPORT_CONFLICT) + ) + assert (await rows(session, kind, "external_identities"))[ + 0 + ].verification_state == State.CONFLICTED + with pytest.raises(IdentityAttachmentConflictError): + await decide(session, kind, target, other.event_id, user) + await decide(session, kind, target, other.event_id, user, Action.REJECT) + assert (await rows(session, kind, "external_identities"))[0].external_id == "42" + + +async def test_cannot_steal_identity_or_detach_series_with_owned_issues(identity_probe_db): + _, factory, _ = identity_probe_db + issue, user, parent = await seed(factory, MetadataEntityKind.ISSUE) + async with factory.begin() as session: + await attach_verified_identities( + session, [claim(issue, MetadataEntityKind.ISSUE, action=Action.VERIFY, parent=parent)] + ) + series_id = (await session.get(Issue, issue)).series_id + original = (await rows(session, MetadataEntityKind.SERIES, "identity_events"))[0] + with pytest.raises(IdentityAttachmentConflictError, match="issue"): + await decide( + session, MetadataEntityKind.SERIES, series_id, original.id, user, Action.REJECT + ) + other = Series(title="Other", sort_title="other") + session.add(other) + await session.flush() + saved = await record_identity_observation( + session, claim(other.id, MetadataEntityKind.SERIES, "90") + ) + with pytest.raises(IdentityAttachmentConflictError): + await decide(session, MetadataEntityKind.SERIES, other.id, saved.event_id, user) + + +async def test_review_and_observation_are_rollback_safe(identity_probe_db): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + request = claim(target, kind) + async with factory() as session: + await record_identity_observation(session, request) + await session.rollback() + async with factory.begin() as session: + assert not await rows(session, kind, "identity_events") + saved = await record_identity_observation(session, request) + async with factory() as session: + await decide(session, kind, target, saved.event_id, user) + await session.rollback() + async with factory() as session: + assert not await rows(session, kind, "external_identities") + assert len(await rows(session, kind, "identity_events")) == 1 + + +@pytest.mark.parametrize("kind", list(MetadataEntityKind)) +async def test_claim_display_reflects_active_conflict_not_old_verified_event( + identity_probe_db, kind +): + _, factory, _ = identity_probe_db + target, _, parent = await seed(factory, kind) + async with factory.begin() as session: + original = ( + await attach_verified_identities( + session, [claim(target, kind, action=Action.VERIFY, parent=parent)] + ) + )[0] + await record_identity_observation( + session, claim(target, kind, "99", Action.REPORT_CONFLICT, parent) + ) + preview = await preview_identity_review(session, kind, target, original.event_id) + assert preview["verification_state"] == State.CONFLICTED + items, total = await list_identity_claims(session, kind, target, limit=1, offset=1) + assert total == 2 and len(items) == 1 + assert items[0]["external_id"] == "42" + assert items[0]["verification_state"] == State.CONFLICTED + + +@pytest.mark.parametrize("problem", ["missing", "wrong", "malformed", "drift"]) +async def test_issue_parent_proof_blocks_confirmation_but_can_be_rejected( + identity_probe_db, problem +): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.ISSUE + target, user, parent = await seed(factory, kind) + if problem == "missing": + parent = None + elif problem == "wrong": + parent = ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "999") + async with factory.begin() as session: + saved = await record_identity_observation(session, claim(target, kind, parent=parent)) + if problem == "malformed": + table = Base.metadata.tables["issue_identity_events"] + await session.execute( + update(table) + .where(table.c.id == saved.event_id) + .values(request_json='{"invalid":true}') + ) + preview = await preview_identity_review(session, kind, target, saved.event_id) + if problem == "drift": + table = Base.metadata.tables["series_external_identities"] + await session.execute( + update(table).values(verification_state=State.CONFLICTED, revision=2) + ) + with pytest.raises((IdentityAttachmentConflictError, IdentityReviewRequiredError)): + await decide(session, kind, target, saved.event_id, user, preview=preview) + await decide(session, kind, target, saved.event_id, user, Action.REJECT) + assert not await rows(session, kind, "external_identities") + assert (await rows(session, kind, "identity_events"))[ + -1 + ].verification_state == State.REJECTED + + +@pytest.mark.parametrize("namespace", list(IdentityNamespace)) +async def test_review_namespaces_remain_independent(identity_probe_db, namespace): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + from pullbox.core.metadata_identity_events import ( + IdentityEvidenceLocator, + IdentityEvidenceRecordKind, + ) + + identity = ExternalIdentityRef(namespace, kind, "12") + request = IdentityEventRequest( + uuid4(), + target, + Action.OBSERVE, + IdentityEventEvidence( + ExactIdentityEvidence(identity, IdentityEvidenceKind.COMICINFO_XML), + "a" * 64, + locator=IdentityEvidenceLocator(IdentityEvidenceRecordKind.SERIES, target), + ), + ) + async with factory.begin() as session: + saved = await record_identity_observation(session, request) + confirmed = await decide(session, kind, target, saved.event_id, user) + legacy = (await session.get(Series, target)).comicvine_id + assert legacy == (12 if namespace is IdentityNamespace.COMICVINE else None) + event = (await rows(session, kind, "identity_events"))[-1] + assert json.loads(event.request_json)["origin"] == { + "record_kind": "series_identity_event", + "record_id": saved.event_id, + } + await decide(session, kind, target, confirmed.event_id, user, Action.REJECT) + assert not await rows(session, kind, "external_identities") + await session.refresh(await session.get(Series, target)) + assert (await session.get(Series, target)).comicvine_id is None + + +@pytest.mark.parametrize( + "actions", [(Action.CONFIRM, Action.CONFIRM), (Action.CONFIRM, Action.REJECT)] +) +async def test_concurrent_reviews_serialize_or_replay_without_lost_decisions( + identity_probe_db, actions +): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + async with factory.begin() as session: + saved = await record_identity_observation(session, claim(target, kind)) + snapshot = await preview_identity_review(session, kind, target, saved.event_id) + + async def run(action): + async with factory.begin() as session: + try: + return await decide( + session, kind, target, saved.event_id, user, action, preview=snapshot + ) + except IdentityReviewRequiredError: + return None + + receipts = await asyncio.wait_for(asyncio.gather(*(run(action) for action in actions)), 15) + assert ( + len([receipt for receipt in receipts if receipt is not None and not receipt.replayed]) == 1 + ) + if actions[0] is actions[1]: + assert all(receipt is not None for receipt in receipts) + else: + assert receipts.count(None) == 1 + async with factory() as session: + assert len(await rows(session, kind, "identity_events")) == 2 + + +async def test_old_successful_confirmation_replay_never_revives_rejected_owner(identity_probe_db): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + target, user, _ = await seed(factory, kind) + async with factory.begin() as session: + saved = await record_identity_observation(session, claim(target, kind)) + snapshot = await preview_identity_review(session, kind, target, saved.event_id) + confirmed = await decide(session, kind, target, saved.event_id, user, preview=snapshot) + await decide(session, kind, target, confirmed.event_id, user, Action.REJECT) + replay = await decide(session, kind, target, saved.event_id, user, preview=snapshot) + assert replay.replayed + assert not await rows(session, kind, "external_identities") + + +async def test_two_targets_cannot_confirm_the_same_unowned_identity(identity_probe_db): + _, factory, _ = identity_probe_db + kind = MetadataEntityKind.SERIES + first, user, _ = await seed(factory, kind) + async with factory.begin() as session: + other = Series(title="Another series", sort_title="another series") + session.add(other) + await session.flush() + targets = [first, other.id] + snapshots = [] + for target in targets: + saved = await record_identity_observation(session, claim(target, kind)) + snapshots.append( + ( + saved.event_id, + await preview_identity_review(session, kind, target, saved.event_id), + ) + ) + + async def run(target, snapshot): + async with factory.begin() as session: + try: + return await decide(session, kind, target, snapshot[0], user, preview=snapshot[1]) + except (IdentityAttachmentConflictError, IdentityReviewRequiredError): + return None + + receipts = await asyncio.wait_for( + asyncio.gather( + *(run(target, snapshot) for target, snapshot in zip(targets, snapshots, strict=True)) + ), + 15, + ) + assert receipts.count(None) == 1 + async with factory() as session: + assert len(await rows(session, kind, "external_identities")) == 1 + assert len(await rows(session, kind, "identity_events")) == 3 + + +@pytest.mark.parametrize("action", [Action.VERIFY, Action.CONFIRM, Action.REJECT]) +async def test_observation_cannot_be_used_as_review_or_automatic_verification( + identity_probe_db, action +): + _, factory, _ = identity_probe_db + target, user, _ = await seed(factory, MetadataEntityKind.SERIES) + from pullbox.core.metadata_identity_events import IdentityEventActor + + request = claim(target, MetadataEntityKind.SERIES, action=Action.VERIFY) + # Confirm/reject requests need a typed actor, but that alone is not authorization. + if action is not Action.VERIFY: + request = replace( + claim(target, MetadataEntityKind.SERIES), + action=action, + actor=IdentityEventActor.USER, + actor_user_id=user, + review_revision=1, + ) + async with factory.begin() as session: + with pytest.raises(ValueError, match="non-verifying"): + await record_identity_observation(session, request) From 0989ab483e61a777244da717758878595d1bac90 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 01:59:39 -0700 Subject: [PATCH 18/97] feat: persist metadata source configuration and priorities --- ...3p456_add_metadata_source_configuration.py | 65 +++++ src/pullbox/config.py | 1 + src/pullbox/models/__init__.py | 2 + src/pullbox/models/metadata_source.py | 35 +++ src/pullbox/schemas/metadata_sources.py | 113 +++++++++ src/pullbox/services/metadata_sources.py | 222 ++++++++++++++++++ .../test_source_configuration.py | 109 +++++++++ tests/integration/test_migrations.py | 3 +- 8 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py create mode 100644 src/pullbox/models/metadata_source.py create mode 100644 src/pullbox/schemas/metadata_sources.py create mode 100644 src/pullbox/services/metadata_sources.py create mode 100644 tests/integration/metadata_identity/test_source_configuration.py diff --git a/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py b/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py new file mode 100644 index 00000000..32815b98 --- /dev/null +++ b/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py @@ -0,0 +1,65 @@ +"""Add independent metadata source configuration and priority policy.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "s0m1n2o3p456" +down_revision = "r9l0m1n2o345" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + table = op.create_table( + "metadata_source_configs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("source", sa.String(30), unique=True, nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False), + sa.Column("domain_priorities", sa.JSON(), nullable=False), + sa.Column("settings", sa.JSON(), nullable=False), + sa.Column("credential_secret", sa.Text()), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("last_tested_at", sa.DateTime(timezone=True)), + sa.Column("last_success_at", sa.DateTime(timezone=True)), + sa.Column("last_status", sa.String(40)), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.CheckConstraint( + "source IN ('comicvine_local','comicvine_api','metron_api','gcd_local','gcd_api_v2')", + name="ck_metadata_source_slug", + ), + sa.CheckConstraint("priority BETWEEN 0 AND 1000", name="ck_metadata_source_priority"), + sa.CheckConstraint("revision > 0", name="ck_metadata_source_revision"), + sa.CheckConstraint( + "credential_secret IS NULL OR source IN ('metron_api','gcd_api_v2')", + name="ck_metadata_source_credential_owner", + ), + ) + op.bulk_insert( + table, + [ + { + "source": source, + "enabled": source.startswith("comicvine_"), + "priority": (index + 1) * 10, + "domain_priorities": {}, + "settings": {}, + "revision": 1, + } + for index, source in enumerate( + ("comicvine_local", "comicvine_api", "metron_api", "gcd_local", "gcd_api_v2") + ) + ], + ) + + +def downgrade() -> None: + # Explicit schema downgrade removes source policy/credentials, not identities + # or the existing ComicVine credential in system_config. + op.drop_table("metadata_source_configs") diff --git a/src/pullbox/config.py b/src/pullbox/config.py index 38fc8bc8..b3719515 100644 --- a/src/pullbox/config.py +++ b/src/pullbox/config.py @@ -104,6 +104,7 @@ class PullboxSettings(BaseSettings): # ── ComicVine ────────────────────────────────────────────────────── comicvine_api_key: str = "" + metadata_gcd_api_v2_enabled: bool = False comicvine_rate_limit: int = 200 # Requests per hour # ── Import Debug ─────────────────────────────────────────────────── diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index 2e98670f..21daecea 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -67,6 +67,7 @@ SeriesIdentityEvent, StoryArcIdentityEvent, ) +from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.operation_progress import ( OperationProgress, OperationProgressState, @@ -193,6 +194,7 @@ "MatchConfidence", "MatchingSuggestion", "MetadataProviderCacheEntry", + "MetadataSourceConfig", "OperationProgress", "OperationProgressState", "OperationProgressTone", diff --git a/src/pullbox/models/metadata_source.py b/src/pullbox/models/metadata_source.py new file mode 100644 index 00000000..27dc7235 --- /dev/null +++ b/src/pullbox/models/metadata_source.py @@ -0,0 +1,35 @@ +"""Source configuration is independent of durable provider identities.""" + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, CheckConstraint, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime + + +class MetadataSourceConfig(Base, IdentityMixin, TimestampMixin): + __tablename__ = "metadata_source_configs" + __table_args__ = ( + CheckConstraint( + "source IN ('comicvine_local','comicvine_api','metron_api','gcd_local','gcd_api_v2')", + name="ck_metadata_source_slug", + ), + CheckConstraint("priority BETWEEN 0 AND 1000", name="ck_metadata_source_priority"), + CheckConstraint("revision > 0", name="ck_metadata_source_revision"), + CheckConstraint( + "credential_secret IS NULL OR source IN ('metron_api','gcd_api_v2')", + name="ck_metadata_source_credential_owner", + ), + ) + + source: Mapped[str] = mapped_column(String(30), unique=True, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False) + priority: Mapped[int] = mapped_column(Integer, nullable=False) + domain_priorities: Mapped[dict[str, int]] = mapped_column(JSON, default=dict, nullable=False) + settings: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False) + credential_secret: Mapped[str | None] = mapped_column(Text) + revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + last_tested_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + last_success_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + last_status: Mapped[str | None] = mapped_column(String(40)) diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py new file mode 100644 index 00000000..55f3e14b --- /dev/null +++ b/src/pullbox/schemas/metadata_sources.py @@ -0,0 +1,113 @@ +"""Versioned source policy and provider-aware discovery responses.""" + +import enum +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource + + +class MetadataDomain(enum.StrEnum): + CORE = "core" + ISSUES = "issues" + ARTWORK = "artwork" + STORY_ARCS = "story_arcs" + + +class SourceCapability(enum.StrEnum): + SERIES_SEARCH = "series_search" + SERIES_DETAILS = "series_details" + ISSUE_LIST = "issue_list" + ISSUE_DETAILS = "issue_details" + CROSS_IDENTITIES = "cross_identities" + CONDITIONAL_REFRESH = "conditional_refresh" + OFFLINE = "offline" + COVER_REFERENCE = "cover_reference" + STORY_ARC_SEARCH = "story_arc_search" + STORY_ARC_DETAILS = "story_arc_details" + + +class SourceStatus(enum.StrEnum): + OK = "ok" + EMPTY = "empty" + DISABLED = "disabled" + FEATURE_DISABLED = "feature_disabled" + NOT_IMPLEMENTED = "not_implemented" + UNCONFIGURED = "unconfigured" + INVALID_CONFIG = "invalid_configuration" + AUTHENTICATION_FAILED = "authentication_failed" + RATE_LIMITED = "rate_limited" + TIMEOUT = "timeout" + UNAVAILABLE = "unavailable" + INCOMPATIBLE_RESPONSE = "incompatible_response" + UNSUPPORTED = "unsupported" + + +class SourceSettings(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + database_path: str | None = Field(default=None, min_length=1, max_length=4096) + + +class SourcePolicyWrite(BaseModel): + model_config = ConfigDict(extra="forbid") + revision: int = Field(ge=0, lt=2**63, strict=True) + enabled: bool + priority: int = Field(ge=0, le=1000, strict=True) + domain_priorities: dict[MetadataDomain, int] = Field(default_factory=dict, max_length=4) + settings: SourceSettings = Field(default_factory=SourceSettings) + credential: SecretStr | None = Field(default=None, exclude=True) + clear_credential: bool = False + + +class SourcePolicyRead(BaseModel): + source: MetadataSource + identity_namespace: IdentityNamespace + enabled: bool + priority: int + domain_priorities: dict[MetadataDomain, int] + settings: SourceSettings + credential_configured: bool + revision: int + last_tested_at: datetime | None = None + last_success_at: datetime | None = None + last_status: SourceStatus | None = None + configuration_status: SourceStatus | None = None + + +class SeriesDiscoveryQuery(BaseModel): + model_config = ConfigDict(extra="forbid") + query: str = Field(min_length=1, max_length=300) + year: int | None = Field(default=None, ge=1, le=9999) + sources: list[MetadataSource] | None = Field(default=None, min_length=1, max_length=5) + mode: Literal["interactive", "automatic"] = "interactive" + limit_per_source: int = Field(default=20, ge=1, le=100) + offsets: dict[MetadataSource, int] = Field(default_factory=dict, max_length=5) + + +class ProviderSeriesRead(BaseModel): + source: MetadataSource + identity_namespace: IdentityNamespace + external_id: str + title: str + year_start: int | None = None + publisher: str | None = None + issue_count: int | None = None + description: str | None = None + resource_url: str | None = None + image_url: str | None = None + also_from: list[MetadataSource] = Field(default_factory=list) + + +class SourceOutcome(BaseModel): + source: MetadataSource + status: SourceStatus + total: int | None = None + next_offset: int | None = None + retry_after_seconds: int | None = None + + +class SeriesDiscoveryRead(BaseModel): + results: list[ProviderSeriesRead] + sources: list[SourceOutcome] diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py new file mode 100644 index 00000000..b00c1675 --- /dev/null +++ b/src/pullbox/services/metadata_sources.py @@ -0,0 +1,222 @@ +"""Persist validated source policy without executing providers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +from pydantic import SecretStr, ValidationError +from sqlalchemy import false, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.config import get_settings +from pullbox.core.comicvine_key import get_comicvine_api_key, save_comicvine_api_key +from pullbox.core.encryption import decrypt_secret, encrypt_secret, is_encrypted +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models.config import SystemConfig +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.metadata_sources import ( + MetadataDomain, + SourcePolicyRead, + SourcePolicyWrite, + SourceSettings, + SourceStatus, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +class SourceConfigurationConflictError(ValueError): + """Source policy was edited after the displayed revision.""" + + +@dataclass(frozen=True) +class SourceRuntime: + policy: SourcePolicyRead + credential: SecretStr | None = None + unavailable: SourceStatus | None = None + + +def default_policy(source: MetadataSource) -> SourcePolicyRead: + return SourcePolicyRead( + source=source, + identity_namespace=source.identity_namespace, + enabled=source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.COMICVINE_API}, + priority=(list(MetadataSource).index(source) + 1) * 10, + domain_priorities={}, + settings=SourceSettings(), + credential_configured=False, + revision=0, + ) + + +async def read_source_policies(session: AsyncSession) -> list[SourcePolicyRead]: + rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))} + cv_row = await session.get(SystemConfig, "comicvine_api_key") + cv_configured = bool((cv_row and cv_row.value) or get_settings().comicvine_api_key) + result = [] + for source in MetadataSource: + policy = default_policy(source) + row = rows.get(source.value) + if row is not None: + try: + body = SourcePolicyWrite( + revision=row.revision, + enabled=row.enabled, + priority=row.priority, + domain_priorities=row.domain_priorities, + settings=row.settings, + ) + _validate(source, body, gcd_api_enabled=True) + policy = SourcePolicyRead( + **{ + **policy.model_dump(), + **body.model_dump(exclude={"clear_credential"}), + "last_tested_at": row.last_tested_at, + "last_success_at": row.last_success_at, + "last_status": row.last_status, + } + ) + except (ValueError, ValidationError): + policy = policy.model_copy( + update={ + "enabled": False, + "revision": row.revision, + "configuration_status": SourceStatus.INVALID_CONFIG, + } + ) + policy.credential_configured = ( + cv_configured + if source is MetadataSource.COMICVINE_API + else bool(row and row.credential_secret) + ) + result.append(policy) + return sorted(result, key=lambda item: (item.priority, item.source.value)) + + +def _validate(source: MetadataSource, body: SourcePolicyWrite, *, gcd_api_enabled: bool) -> None: + if source is MetadataSource.GCD_API_V2 and body.enabled and not gcd_api_enabled: + raise ValueError("GCD API v2 is disabled by the release feature flag") + if any( + type(value) is not int or not 0 <= value <= 1000 + for value in body.domain_priorities.values() + ): + raise ValueError("Domain priorities must be integers between 0 and 1000") + if ( + source in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2} + and MetadataDomain.ARTWORK in body.domain_priorities + ): + raise ValueError("This source does not provide artwork") + path = body.settings.database_path + if path is not None and ( + source is not MetadataSource.GCD_LOCAL + or not PurePosixPath(path).is_absolute() + or ".." in PurePosixPath(path).parts + or "\x00" in path + ): + raise ValueError("Only GCD Local accepts an absolute database candidate path") + if body.credential is not None and body.clear_credential: + raise ValueError("Choose a replacement credential or clear it, not both") + if (body.credential is not None or body.clear_credential) and source in { + MetadataSource.COMICVINE_LOCAL, + MetadataSource.GCD_LOCAL, + }: + raise ValueError("A local source does not accept credentials") + if body.credential is not None: + token = body.credential.get_secret_value() + if ( + not token + or len(token) > 4096 + or token.startswith("enc:") + or any(char.isspace() for char in token) + ): + raise ValueError("Enter a non-empty provider token, not an encrypted value") + + +async def save_source_policy( + session: AsyncSession, + source: MetadataSource, + body: SourcePolicyWrite, + *, + gcd_api_enabled: bool = False, +) -> SourcePolicyRead: + _validate(source, body, gcd_api_enabled=gcd_api_enabled) + encrypted = ( + encrypt_secret(body.credential.get_secret_value()) if body.credential is not None else None + ) + if session.get_bind().dialect.name == "sqlite": + await session.execute( + update(MetadataSourceConfig) + .where(false()) + .values(revision=MetadataSourceConfig.revision) + ) + try: + async with session.begin_nested(): + row = await session.scalar( + select(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .with_for_update() + .execution_options(populate_existing=True) + ) + if (row.revision if row else 0) != body.revision: + raise SourceConfigurationConflictError( + "Source settings changed; reload before saving" + ) + if row is None: + row = MetadataSourceConfig(source=source.value) + session.add(row) + row.enabled, row.priority = body.enabled, body.priority + row.domain_priorities = { + key.value: value for key, value in body.domain_priorities.items() + } + row.settings = body.settings.model_dump(exclude_none=True) + row.revision = body.revision + 1 + row.last_status = None + row.last_tested_at = row.last_success_at = None + if source is MetadataSource.COMICVINE_API: + if body.credential is not None or body.clear_credential: + await save_comicvine_api_key( + session, body.credential.get_secret_value() if body.credential else "" + ) + elif encrypted is not None or body.clear_credential: + row.credential_secret = encrypted if encrypted is not None else None + await session.flush() + except IntegrityError as exc: + raise SourceConfigurationConflictError( + "Source settings changed; reload before saving" + ) from exc + return next(item for item in await read_source_policies(session) if item.source is source) + + +async def load_source_runtime( + session: AsyncSession, *, gcd_api_enabled: bool +) -> list[SourceRuntime]: + """Load configuration only. Disabled and flagged sources never decrypt secrets.""" + policies = await read_source_policies(session) + rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))} + result = [] + for policy in policies: + status = policy.configuration_status + if policy.source is MetadataSource.GCD_API_V2 and not gcd_api_enabled: + status = SourceStatus.FEATURE_DISABLED + elif not policy.enabled: + status = status or SourceStatus.DISABLED + credential = None + if status is None: + try: + if policy.source is MetadataSource.COMICVINE_API: + token = await get_comicvine_api_key(session) + credential = SecretStr(token) if token else None + elif policy.source in {MetadataSource.METRON_API, MetadataSource.GCD_API_V2}: + row = rows.get(policy.source.value) + encrypted = row.credential_secret if row else None + if encrypted and not is_encrypted(encrypted): + raise ValueError("Source credential is not encrypted") + token = decrypt_secret(encrypted) if encrypted else "" + credential = SecretStr(token) if token else None + except ValueError: + status = SourceStatus.INVALID_CONFIG + result.append(SourceRuntime(policy, credential, status)) + return result diff --git a/tests/integration/metadata_identity/test_source_configuration.py b/tests/integration/metadata_identity/test_source_configuration.py new file mode 100644 index 00000000..9e5777c9 --- /dev/null +++ b/tests/integration/metadata_identity/test_source_configuration.py @@ -0,0 +1,109 @@ +"""Source policy storage, secret boundaries, and revision checks on both engines.""" + +import asyncio + +import pytest +from pydantic import SecretStr +from sqlalchemy import select + +from pullbox.core.comicvine_key import get_comicvine_api_key +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.metadata_sources import SourcePolicyWrite +from pullbox.services.metadata_sources import ( + SourceConfigurationConflictError, + read_source_policies, + save_source_policy, +) + + +def policy(**kwargs): + return SourcePolicyWrite(revision=0, enabled=True, priority=7, **kwargs) + + +async def test_source_policy_is_durable_and_secrets_are_never_read_back(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + saved = await save_source_policy( + session, + MetadataSource.METRON_API, + policy( + credential=SecretStr("synthetic-provider-token"), domain_priorities={"issues": 3} + ), + ) + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "metron_api") + ) + assert row is not None + assert row.credential_secret.startswith("enc:") + assert "synthetic-provider-token" not in row.credential_secret + assert saved.revision == 1 and saved.credential_configured + assert "synthetic-provider-token" not in saved.model_dump_json() + async with factory() as session: + result = {item.source: item for item in await read_source_policies(session)} + assert result[MetadataSource.METRON_API].priority == 7 + assert result[MetadataSource.METRON_API].domain_priorities == {"issues": 3} + + +async def test_comicvine_configuration_reuses_the_existing_key_store(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + await save_source_policy( + session, MetadataSource.COMICVINE_API, policy(credential=SecretStr("synthetic-cv-key")) + ) + assert await get_comicvine_api_key(session) == "synthetic-cv-key" + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "comicvine_api") + ) + assert row.credential_secret is None + + +async def test_reads_do_not_write_defaults_and_caller_controls_commit(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory() as session: + result = await read_source_policies(session) + assert len(result) == 5 + assert [item.source for item in result if item.enabled] == [ + MetadataSource.COMICVINE_LOCAL, + MetadataSource.COMICVINE_API, + ] + assert list(await session.scalars(select(MetadataSourceConfig))) == [] + await save_source_policy(session, MetadataSource.METRON_API, policy()) + await session.rollback() + async with factory() as session: + assert list(await session.scalars(select(MetadataSourceConfig))) == [] + + +@pytest.mark.parametrize( + "source,body", + [ + (MetadataSource.GCD_API_V2, policy()), + (MetadataSource.COMICVINE_LOCAL, policy(credential=SecretStr("not-allowed"))), + (MetadataSource.METRON_API, policy(credential=SecretStr("enc:not-plaintext"))), + (MetadataSource.METRON_API, policy(credential=SecretStr("token"), clear_credential=True)), + (MetadataSource.GCD_LOCAL, policy(domain_priorities={"artwork": 1})), + (MetadataSource.METRON_API, policy(domain_priorities={"core": -1})), + (MetadataSource.METRON_API, policy(settings={"database_path": "/unrelated/path"})), + ], +) +async def test_invalid_source_policy_is_rejected_before_any_write(identity_probe_db, source, body): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + with pytest.raises(ValueError): + await save_source_policy(session, source, body) + assert list(await session.scalars(select(MetadataSourceConfig))) == [] + + +async def test_concurrent_updates_cannot_overwrite_a_newer_policy(identity_probe_db): + _, factory, _ = identity_probe_db + + async def save(): + try: + async with factory.begin() as session: + return await save_source_policy(session, MetadataSource.METRON_API, policy()) + except SourceConfigurationConflictError: + return None + + results = await asyncio.wait_for(asyncio.gather(save(), save()), 15) + assert results.count(None) == 1 + assert next(item for item in results if item is not None).revision == 1 diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index d0ad4c7b..ab330f3b 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["r9l0m1n2o345"] + assert script.get_heads() == ["s0m1n2o3p456"] + assert script.get_revision("s0m1n2o3p456").down_revision == "r9l0m1n2o345" assert script.get_revision("r9l0m1n2o345").down_revision == "q8k9l0m1n234" assert script.get_revision("q8k9l0m1n234").down_revision == "p7j8k9l0m123" assert script.get_revision("p7j8k9l0m123").down_revision == "o6i7j8k9l012" From 0cd78bc57cd5a16e3dbccf9b499b82597b1ded16 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 02:54:11 -0700 Subject: [PATCH 19/97] feat: add bounded metadata source discovery and health APIs --- docs/development/ARCHITECTURE_OVERVIEW.md | 19 ++ docs/development/DATABASE_STANDARDS.md | 15 + src/pullbox/api/v1/metadata_sources.py | 118 ++++++++ src/pullbox/api/v1/router.py | 2 + src/pullbox/providers/metadata/comicvine.py | 11 + src/pullbox/providers/metadata/sources.py | 237 +++++++++++++++ src/pullbox/schemas/metadata_sources.py | 34 ++- src/pullbox/services/metadata_discovery.py | 234 +++++++++++++++ src/pullbox/services/metadata_sources.py | 37 ++- tests/api/test_metadata_sources_api.py | 148 ++++++++++ .../test_source_configuration.py | 129 ++++++++ tests/unit/test_metadata_discovery.py | 250 ++++++++++++++++ tests/unit/test_metadata_source_adapters.py | 277 ++++++++++++++++++ 13 files changed, 1507 insertions(+), 4 deletions(-) create mode 100644 src/pullbox/api/v1/metadata_sources.py create mode 100644 src/pullbox/providers/metadata/sources.py create mode 100644 src/pullbox/services/metadata_discovery.py create mode 100644 tests/api/test_metadata_sources_api.py create mode 100644 tests/unit/test_metadata_discovery.py create mode 100644 tests/unit/test_metadata_source_adapters.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index e2e9fc06..8d442ddf 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -303,6 +303,25 @@ constructed, and provider instances are registered for service use. AirDC++ uses `src/pullbox/composition/airdcpp.py` because its authenticated REST session and WebSocket lifecycle are supervised per exact configured client. +The v2 metadata platform has a separate source-aware registry in +`services/metadata_discovery.py`. Its ComicVine local/API adapters share the +ComicVine identity namespace but retain separate priorities, capabilities, +pagination, and typed outcomes. Interactive discovery fans out with bounded +concurrency; automatic discovery cascades and stops only when its caller's +explicit satisfaction predicate succeeds. Search results do not attach identities. + +`/api/v1/metadata/sources` exposes operator-only configuration and connection +checks; `/api/v1/metadata/search` requires authentication. Session writes use +the existing CSRF contract. Database reads end before provider I/O, clients close +after each operation, and one provider failure does not hide other results. +Read-only local catalog work retains at most two owned in-flight operations per +event loop. Cancelled requests can return without abandoning disk cleanup or +creating an unbounded background queue. +The existing import/cache and Add Series/Story Arc consumers are not yet migrated +to this registry. Metron and GCD execution remain unimplemented; GCD API v2 is +also disabled by the release feature flag. Capability lists describe implemented +adapter operations, not planned features. + **Required standard** - Keep provider quirks inside provider modules. diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 7210bd5f..57e86a1b 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -391,6 +391,21 @@ Review, observation, and attachment share parent lock ordering and caller-owned transactions. Compatibility ComicVine columns and retained history change atomically with ownership. Do not bypass these services with ad hoc ID updates. +### Metadata Source Configuration + +`metadata_source_configs` stores one revision-checked policy per executable source, +using the normal integer identity and timestamp mixins plus a unique source slug. +It has no cascading relationship to provider identities. Reads do not create rows; +the migration seeds default ComicVine local/API policies and disabled other sources. +The canonical ComicVine credential remains in its existing configuration key. +Other API tokens use encrypted columns, never the ordinary settings JSON or read DTOs. + +Services leave transaction ownership to callers. Settings writes use revision +checks and row locks (plus a SQLite write transaction before the savepoint). +Health checks perform no provider I/O inside a database transaction. Their short +result write applies only to the tested revision and cannot replace a newer probe. +Cached health is diagnostic, not proof that a source is safe or available now. + ### 4.1 Base Model Mixins **Current Pullbox implementation** diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py new file mode 100644 index 00000000..7c456437 --- /dev/null +++ b/src/pullbox/api/v1/metadata_sources.py @@ -0,0 +1,118 @@ +"""Metadata source configuration, discovery and explicit connection checks.""" + +from datetime import UTC, datetime + +import structlog +from fastapi import APIRouter, HTTPException + +from pullbox.api.deps import AuthenticatedUser, DbSession, InteractiveOperatorUser, Settings +from pullbox.core.metadata_identity import MetadataSource +from pullbox.providers.metadata.sources import comicvine_sources +from pullbox.schemas.metadata_sources import ( + SeriesDiscoveryQuery, + SeriesDiscoveryRead, + SourceDescriptor, + SourcePolicyRead, + SourcePolicyWrite, + SourceStatus, + SourceTestRead, +) +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_sources import ( + SourceConfigurationConflictError, + load_source_runtime, + read_source_policies, + record_source_health, + save_source_policy, +) + +router = APIRouter(prefix="/metadata", tags=["metadata"]) +logger = structlog.get_logger(__name__) + + +@router.get("/sources", response_model=list[SourceDescriptor]) +async def sources( + session: DbSession, _user: InteractiveOperatorUser, settings: Settings +) -> list[SourceDescriptor]: + registrations = comicvine_sources() + result = [] + for policy in await read_source_policies(session): + registration = registrations.get(policy.source) + availability = policy.configuration_status + if policy.source is MetadataSource.GCD_API_V2 and not settings.metadata_gcd_api_v2_enabled: + availability = SourceStatus.FEATURE_DISABLED + elif not policy.enabled: + availability = availability or SourceStatus.DISABLED + elif registration is None: + availability = SourceStatus.NOT_IMPLEMENTED + elif policy.source is MetadataSource.COMICVINE_API and not policy.credential_configured: + availability = SourceStatus.UNCONFIGURED + result.append( + SourceDescriptor( + **policy.model_dump(), + capabilities=sorted(registration.capabilities) if registration else [], + availability=availability, + ) + ) + return result + + +@router.put("/sources/{source}", response_model=SourcePolicyRead) +async def save_source( + source: MetadataSource, + body: SourcePolicyWrite, + session: DbSession, + user: InteractiveOperatorUser, + settings: Settings, +) -> SourcePolicyRead: + try: + result = await save_source_policy( + session, source, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + ) + except SourceConfigurationConflictError as exc: + raise HTTPException(409, str(exc)) from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + logger.info( + "metadata_source_policy_updated", + source=source.value, + revision=result.revision, + enabled=result.enabled, + user_id=user.id, + ) + return result + + +@router.post("/search", response_model=SeriesDiscoveryRead) +async def search( + body: SeriesDiscoveryQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings +) -> SeriesDiscoveryRead: + runtime = await load_source_runtime( + session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + ) + await session.rollback() + registry = MetadataSourceRegistry(runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) + return await registry.discover(body) + + +@router.post("/sources/{source}/test", response_model=SourceTestRead) +async def test_source( + source: MetadataSource, session: DbSession, _user: InteractiveOperatorUser, settings: Settings +) -> SourceTestRead: + runtime = await load_source_runtime( + session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + ) + revision = next(item.policy.revision for item in runtime if item.policy.source is source) + await session.rollback() + checked_at = datetime.now(UTC) + outcome = await MetadataSourceRegistry( + runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + ).check(source) + recorded = await record_source_health(session, source, revision, outcome, checked_at) + logger.info( + "metadata_source_test_complete", + source=source.value, + status=outcome.status.value, + recorded=recorded, + ) + return SourceTestRead(outcome=outcome, recorded=recorded) diff --git a/src/pullbox/api/v1/router.py b/src/pullbox/api/v1/router.py index 08ce2571..95036bee 100644 --- a/src/pullbox/api/v1/router.py +++ b/src/pullbox/api/v1/router.py @@ -25,6 +25,7 @@ from pullbox.api.v1.issues import router as issues_router from pullbox.api.v1.library import router as library_router from pullbox.api.v1.metadata_identities import router as metadata_identities_router +from pullbox.api.v1.metadata_sources import router as metadata_sources_router from pullbox.api.v1.reader import router as reader_router from pullbox.api.v1.search import router as search_router from pullbox.api.v1.series import router as series_router @@ -50,6 +51,7 @@ v1_router.include_router(issues_router) v1_router.include_router(library_router) v1_router.include_router(metadata_identities_router) +v1_router.include_router(metadata_sources_router) v1_router.include_router(reader_router) v1_router.include_router(downloads_router) v1_router.include_router(direct_providers_router) diff --git a/src/pullbox/providers/metadata/comicvine.py b/src/pullbox/providers/metadata/comicvine.py index c1d00281..db78afe9 100644 --- a/src/pullbox/providers/metadata/comicvine.py +++ b/src/pullbox/providers/metadata/comicvine.py @@ -396,10 +396,12 @@ def __init__( *, retryable: bool = False, retry_after_seconds: int | None = None, + timed_out: bool = False, ) -> None: self.status_code = status_code self.retryable = retryable self.retry_after_seconds = retry_after_seconds + self.timed_out = timed_out super().__init__(message) @@ -495,6 +497,7 @@ async def _request( 0, f"Request timed out: {endpoint}", retryable=True, + timed_out=True, ) from None except httpx.HTTPStatusError as exc: http_status = exc.response.status_code @@ -600,6 +603,7 @@ async def search_series_page( limit: int = 20, offset: int = 0, suppress_errors: bool = True, + strict_response: bool = False, ) -> tuple[list[SeriesSearchResult], int]: """Search for series and return the current page plus ComicVine's total.""" log = logger.bind(query=query, year=year, limit=limit, offset=offset) @@ -624,6 +628,13 @@ async def search_series_page( return [], 0 raise + if strict_response and ( + not isinstance(data.get("results"), list) + or type(data.get("number_of_total_results")) is not int + or data["number_of_total_results"] < 0 + ): + raise ValueError("Invalid ComicVine search response") + total_results = _safe_int(data.get("number_of_total_results")) or 0 results: list[SeriesSearchResult] = [] diff --git a/src/pullbox/providers/metadata/sources.py b/src/pullbox/providers/metadata/sources.py new file mode 100644 index 00000000..212b2c0e --- /dev/null +++ b/src/pullbox/providers/metadata/sources.py @@ -0,0 +1,237 @@ +"""Executable ComicVine adapters; keep legacy import/cache consumers unchanged.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast +from urllib.parse import urlsplit + +from pullbox.config import get_settings +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider +from pullbox.schemas.metadata_sources import ( + ProviderSeriesRead, + SeriesDiscoveryQuery, + SourceCapability, + SourceStatus, +) +from pullbox.services.catalog.contract import CatalogError +from pullbox.services.catalog.reader import get_catalog_reader +from pullbox.services.catalog.storage import disk_work +from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage, SourceRegistration + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from pullbox.providers.base import SeriesSearchResult + from pullbox.services.catalog.reader import CatalogReader + from pullbox.services.metadata_sources import SourceRuntime + + +_catalog_reads: dict[asyncio.AbstractEventLoop, set[asyncio.Task[object]]] = {} + + +async def _catalog_read[T](operation: Callable[[], Awaitable[T]]) -> T: + """Return promptly on cancellation without spawning unbounded disk workers.""" + loop = asyncio.get_running_loop() + reads = _catalog_reads.setdefault(loop, set()) + if len(reads) >= 2: + raise MetadataSourceError(SourceStatus.UNAVAILABLE, retry_after_seconds=1) + + async def owned_read() -> object: + return await operation() + + def finished(task: asyncio.Task[object]) -> None: + reads.discard(task) + if not task.cancelled(): + task.exception() + if not reads: + _catalog_reads.pop(loop, None) + + task = asyncio.create_task(owned_read(), name="metadata-catalog-read") + reads.add(task) + task.add_done_callback(finished) + try: + return cast("T", await asyncio.shield(task)) + except asyncio.CancelledError: + # disk_work joins its thread on cancellation. Retain the slot and task + # until that owned read finishes; never abandon a database/file handle. + task.cancel() + raise + + +def _image_url(value: str | None) -> str | None: + if not value or len(value) > 4096 or any(char.isspace() for char in value): + return None + try: + parsed = urlsplit(value) + host = parsed.hostname or "" + if ( + parsed.scheme == "https" + and parsed.port in {None, 443} + and not parsed.username + and not parsed.password + and not parsed.query + and not parsed.fragment + and ( + host in {"comicvine.gamespot.com", "comicvine.com"} + or host.endswith(".cbsistatic.com") + ) + ): + return value + except ValueError: + pass + return None + + +def _page( + source: MetadataSource, + rows: list[SeriesSearchResult], + *, + total: int | None, + offset: int, + limit: int, + has_more: bool, +) -> SourcePage: + results = [] + rejected = 0 + for row in rows[:limit]: + try: + identity = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.SERIES, row.provider_id + ) + if not row.title or not row.title.strip(): + raise ValueError("A series title is required") + results.append( + ProviderSeriesRead( + source=source, + identity_namespace=source.identity_namespace, + external_id=identity.external_id, + title=row.title[:500], + year_start=row.year_start, + publisher=row.publisher[:500] if row.publisher else None, + issue_count=row.issue_count, + description=row.description[:20000] if row.description else None, + resource_url=f"https://comicvine.gamespot.com/volume/4050-{identity.external_id}/", + image_url=_image_url(row.cover_url), + ) + ) + except (ValueError, TypeError, AttributeError): + rejected += 1 + next_offset = offset + limit if has_more and offset + limit <= 10000 else None + return SourcePage(results, total, next_offset, rejected, has_more and next_offset is None) + + +def _api_error(exc: ComicVineError) -> MetadataSourceError: + if exc.timed_out or exc.status_code in {408, 504}: + status = SourceStatus.TIMEOUT + elif exc.status_code in {100, 401, 403}: + status = SourceStatus.AUTHENTICATION_FAILED + elif exc.status_code in {107, 420, 429}: + status = SourceStatus.RATE_LIMITED + else: + status = SourceStatus.UNAVAILABLE + return MetadataSourceError(status, exc.retry_after_seconds) + + +class ComicVineApiSource: + def __init__(self, provider: ComicVineProvider) -> None: + self.provider = provider + + async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: + try: + rows, total = await self.provider.search_series_page( + query.query, + query.year, + limit=query.limit_per_source, + offset=offset, + suppress_errors=False, + strict_response=True, + ) + if total < len(rows) or len(rows) > query.limit_per_source: + raise ValueError("Inconsistent source page") + return _page( + MetadataSource.COMICVINE_API, + rows, + total=total, + offset=offset, + limit=query.limit_per_source, + has_more=offset + query.limit_per_source < total, + ) + except ComicVineError as exc: + raise _api_error(exc) from None + except (ValueError, TypeError, AttributeError, KeyError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + + async def check(self) -> None: + page = await self.search(SeriesDiscoveryQuery(query="test", limit_per_source=1), 0) + if page.rejected_results: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + + async def close(self) -> None: + await self.provider.close() + + +class ComicVineLocalSource: + def __init__(self, reader: CatalogReader) -> None: + self.reader = reader + + async def _available(self) -> None: + if not await disk_work(lambda: self.reader.available): + raise MetadataSourceError(SourceStatus.UNCONFIGURED) + + async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: + return await _catalog_read(lambda: self._search(query, offset)) + + async def _search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: + await self._available() + try: + rows = await self.reader.search( + query.query, query.year, query.limit_per_source + 1, offset + ) + except (CatalogError, OSError, ValueError): + raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None + return _page( + MetadataSource.COMICVINE_LOCAL, + rows, + total=None, + offset=offset, + limit=query.limit_per_source, + has_more=len(rows) > query.limit_per_source, + ) + + async def check(self) -> None: + await _catalog_read(self._check) + + async def _check(self) -> None: + await self._available() + try: + # Validate/open the active generation, even if it has no matching series. + await self.reader.series(1) + except (CatalogError, OSError, ValueError): + raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None + + async def close(self) -> None: + return None + + +def _api(runtime: SourceRuntime) -> ComicVineApiSource: + if runtime.credential is None or not runtime.credential.get_secret_value(): + raise MetadataSourceError(SourceStatus.UNCONFIGURED) + return ComicVineApiSource( + ComicVineProvider( + runtime.credential.get_secret_value(), rate_limit=get_settings().comicvine_rate_limit + ) + ) + + +def comicvine_sources() -> dict[MetadataSource, SourceRegistration]: + return { + MetadataSource.COMICVINE_API: SourceRegistration( + frozenset({SourceCapability.SERIES_SEARCH}), _api + ), + MetadataSource.COMICVINE_LOCAL: SourceRegistration( + frozenset({SourceCapability.SERIES_SEARCH, SourceCapability.OFFLINE}), + lambda runtime: ComicVineLocalSource(get_catalog_reader()), + ), + } diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index 55f3e14b..21974df6 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -2,9 +2,9 @@ import enum from datetime import datetime -from typing import Literal +from typing import Annotated, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource @@ -43,6 +43,7 @@ class SourceStatus(enum.StrEnum): UNAVAILABLE = "unavailable" INCOMPATIBLE_RESPONSE = "incompatible_response" UNSUPPORTED = "unsupported" + NOT_QUERIED = "not_queried" class SourceSettings(BaseModel): @@ -83,7 +84,22 @@ class SeriesDiscoveryQuery(BaseModel): sources: list[MetadataSource] | None = Field(default=None, min_length=1, max_length=5) mode: Literal["interactive", "automatic"] = "interactive" limit_per_source: int = Field(default=20, ge=1, le=100) - offsets: dict[MetadataSource, int] = Field(default_factory=dict, max_length=5) + offsets: dict[MetadataSource, Annotated[int, Field(ge=0, le=10000, strict=True)]] = Field( + default_factory=dict, max_length=5 + ) + + @model_validator(mode="after") + def validate_selection(self) -> Self: + self.query = self.query.strip() + if not self.query: + raise ValueError("Enter a series title") + if self.sources is not None and len(set(self.sources)) != len(self.sources): + raise ValueError("Choose each source only once") + if self.sources is not None and self.offsets.keys() - set(self.sources): + raise ValueError("Pagination must refer to a selected source") + if any(offset % self.limit_per_source for offset in self.offsets.values()): + raise ValueError("Offsets must start at a source page boundary") + return self class ProviderSeriesRead(BaseModel): @@ -106,8 +122,20 @@ class SourceOutcome(BaseModel): total: int | None = None next_offset: int | None = None retry_after_seconds: int | None = None + rejected_results: int = 0 + truncated: bool = False class SeriesDiscoveryRead(BaseModel): results: list[ProviderSeriesRead] sources: list[SourceOutcome] + + +class SourceDescriptor(SourcePolicyRead): + capabilities: list[SourceCapability] + availability: SourceStatus | None = None + + +class SourceTestRead(BaseModel): + outcome: SourceOutcome + recorded: bool diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py new file mode 100644 index 00000000..052d8f8c --- /dev/null +++ b/src/pullbox/services/metadata_discovery.py @@ -0,0 +1,234 @@ +"""Source-aware metadata discovery; identity attachment is a separate operation.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +import structlog + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.schemas.metadata_sources import ( + MetadataDomain, + ProviderSeriesRead, + SeriesDiscoveryQuery, + SeriesDiscoveryRead, + SourceCapability, + SourceOutcome, + SourceStatus, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from pullbox.services.metadata_sources import SourceRuntime + +logger = structlog.get_logger(__name__) + + +@dataclass(frozen=True) +class SourcePage: + results: list[ProviderSeriesRead] + total: int | None = None + next_offset: int | None = None + rejected_results: int = 0 + truncated: bool = False + + +class MetadataSourceAdapter(Protocol): + async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: ... + async def check(self) -> None: ... + async def close(self) -> None: ... + + +class MetadataSourceError(Exception): + def __init__(self, status: SourceStatus, retry_after_seconds: int | None = None) -> None: + self.status = status + self.retry_after_seconds = retry_after_seconds + super().__init__(status.value) + + +@dataclass(frozen=True) +class SourceRegistration: + capabilities: frozenset[SourceCapability] + factory: Callable[[SourceRuntime], MetadataSourceAdapter] + + +class MetadataSourceRegistry: + def __init__( + self, + runtime: Sequence[SourceRuntime], + *, + factories: Mapping[MetadataSource, SourceRegistration] | None = None, + gcd_api_enabled: bool = False, + per_source_timeout: float = 8, + total_timeout: float = 15, + concurrency: int = 3, + ) -> None: + if factories is None: + from pullbox.providers.metadata.sources import comicvine_sources + + factories = comicvine_sources() + self.runtime = {item.policy.source: item for item in runtime} + self.factories = factories + self.gcd_api_enabled = gcd_api_enabled + if not 0 < per_source_timeout <= 30 or not 0 < total_timeout <= 60: + raise ValueError("Metadata discovery deadlines must be bounded") + if not 1 <= concurrency <= 5: + raise ValueError("Metadata discovery concurrency must be between one and five") + self.per_source_timeout = per_source_timeout + self.total_timeout = total_timeout + self.concurrency = concurrency + + def _unavailable(self, source: MetadataSource, *, search: bool) -> SourceStatus | None: + runtime = self.runtime.get(source) + if source is MetadataSource.GCD_API_V2 and not self.gcd_api_enabled: + return SourceStatus.FEATURE_DISABLED + if runtime is None: + return SourceStatus.UNCONFIGURED + if runtime.unavailable is not None: + return runtime.unavailable + if not runtime.policy.enabled: + return SourceStatus.DISABLED + registration = self.factories.get(source) + if registration is None: + return SourceStatus.NOT_IMPLEMENTED + if search and SourceCapability.SERIES_SEARCH not in registration.capabilities: + return SourceStatus.UNSUPPORTED + return None + + async def _run( + self, + source: MetadataSource, + query: SeriesDiscoveryQuery | None, + semaphore: asyncio.Semaphore, + deadline: float, + ) -> tuple[SourcePage, SourceOutcome]: + unavailable = self._unavailable(source, search=query is not None) + if unavailable is not None: + return SourcePage([]), SourceOutcome(source=source, status=unavailable) + if asyncio.get_running_loop().time() >= deadline: + return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + adapter = None + page = SourcePage([]) + try: + async with asyncio.timeout_at(deadline), semaphore: + async with asyncio.timeout(self.per_source_timeout): + adapter = self.factories[source].factory(self.runtime[source]) + if query is None: + await adapter.check() + else: + page = await adapter.search(query, query.offsets.get(source, 0)) + if any( + item.source != source + or item.identity_namespace != source.identity_namespace + for item in page.results + ): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + if query is not None and len(page.results) > query.limit_per_source: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + status = ( + SourceStatus.INCOMPATIBLE_RESPONSE + if page.rejected_results + else SourceStatus.OK + if page.results or query is None + else SourceStatus.EMPTY + ) + outcome = SourceOutcome( + source=source, + status=status, + total=page.total, + next_offset=page.next_offset, + rejected_results=page.rejected_results, + truncated=page.truncated, + ) + except TimeoutError: + outcome = SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + page = SourcePage([]) + except MetadataSourceError as exc: + outcome = SourceOutcome( + source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds + ) + page = SourcePage([]) + except Exception: + # A provider fault must not leak credentials or hide other source results. + logger.warning("metadata_source_operation_failed", source=source.value) + outcome = SourceOutcome(source=source, status=SourceStatus.UNAVAILABLE) + page = SourcePage([]) + finally: + if adapter is not None: + try: + async with asyncio.timeout(2): + await adapter.close() + except Exception: + logger.warning("metadata_source_close_failed", source=source.value) + return page, outcome + + async def discover( + self, + query: SeriesDiscoveryQuery, + *, + satisfied_by: Callable[[SourcePage], bool] | None = None, + ) -> SeriesDiscoveryRead: + """Cascade only stops when the caller proves its requirements are met.""" + sources = sorted( + query.sources if query.sources is not None else self.runtime, + key=lambda source: ( + self.runtime[source].policy.domain_priorities.get( + MetadataDomain.CORE, self.runtime[source].policy.priority + ) + if source in self.runtime + else 1001, + source.value, + ), + ) + semaphore = asyncio.Semaphore(self.concurrency) + deadline = asyncio.get_running_loop().time() + self.total_timeout + pages = [] + if query.mode == "automatic": + satisfied = False + for source in sources: + if satisfied: + status = self._unavailable(source, search=True) or SourceStatus.NOT_QUERIED + pages.append((SourcePage([]), SourceOutcome(source=source, status=status))) + else: + page, outcome = await self._run(source, query, semaphore, deadline) + pages.append((page, outcome)) + satisfied = ( + bool(page.results) and satisfied_by is not None and satisfied_by(page) + ) + else: + tasks = [ + asyncio.create_task(self._run(source, query, semaphore, deadline)) + for source in sources + ] + try: + pages = list(await asyncio.gather(*tasks)) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + grouped = {} + for page, _ in pages: + for item in page.results: + key = (item.identity_namespace, item.external_id) + if key not in grouped: + grouped[key] = item.model_copy(deep=True) + elif ( + item.source != grouped[key].source and item.source not in grouped[key].also_from + ): + grouped[key].also_from.append(item.source) + return SeriesDiscoveryRead( + results=list(grouped.values()), sources=[outcome for _, outcome in pages] + ) + + async def check(self, source: MetadataSource) -> SourceOutcome: + _, outcome = await self._run( + source, + None, + asyncio.Semaphore(1), + asyncio.get_running_loop().time() + self.total_timeout, + ) + return outcome diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py index b00c1675..9d5a3323 100644 --- a/src/pullbox/services/metadata_sources.py +++ b/src/pullbox/services/metadata_sources.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from pydantic import SecretStr, ValidationError -from sqlalchemy import false, select, update +from sqlalchemy import false, or_, select, update from sqlalchemy.exc import IntegrityError from pullbox.config import get_settings @@ -18,6 +18,7 @@ from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.schemas.metadata_sources import ( MetadataDomain, + SourceOutcome, SourcePolicyRead, SourcePolicyWrite, SourceSettings, @@ -25,6 +26,8 @@ ) if TYPE_CHECKING: + from datetime import datetime + from sqlalchemy.ext.asyncio import AsyncSession @@ -220,3 +223,35 @@ async def load_source_runtime( status = SourceStatus.INVALID_CONFIG result.append(SourceRuntime(policy, credential, status)) return result + + +async def record_source_health( + session: AsyncSession, + source: MetadataSource, + revision: int, + outcome: SourceOutcome, + checked_at: datetime, +) -> bool: + """Apply only to the tested configuration, never over a newer probe.""" + if outcome.source is not source: + raise ValueError("Health outcome belongs to another metadata source") + result = await session.execute( + update(MetadataSourceConfig) + .where( + MetadataSourceConfig.source == source.value, + MetadataSourceConfig.revision == revision, + or_( + MetadataSourceConfig.last_tested_at.is_(None), + MetadataSourceConfig.last_tested_at <= checked_at, + ), + ) + .values( + last_tested_at=checked_at, + last_status=outcome.status.value, + last_success_at=checked_at + if outcome.status is SourceStatus.OK + else MetadataSourceConfig.last_success_at, + ) + .returning(MetadataSourceConfig.id) + ) + return result.scalar_one_or_none() is not None diff --git a/tests/api/test_metadata_sources_api.py b/tests/api/test_metadata_sources_api.py new file mode 100644 index 00000000..4d96c18b --- /dev/null +++ b/tests/api/test_metadata_sources_api.py @@ -0,0 +1,148 @@ +"""Source configuration/search uses real auth and releases DB reads before I/O.""" + +import os +import sys + +from sqlalchemy import select + +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.services.auth_service import SESSION_COOKIE_NAME, AuthService +from tests.unit.test_catalog_reader import installed_reader + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +pytest_plugins = ["conftest_security"] + + +def csrf(client): + return { + "X-CSRF-Token": AuthService.get_csrf_token_from_session( + client.cookies.get(SESSION_COOKIE_NAME) + ) + } + + +def policy(**updates): + return {"revision": 0, "enabled": True, "priority": 5, **updates} + + +async def test_source_policy_roundtrip_masks_secret_and_rejects_stale_revision( + authenticated_client, sec_db +): + url = "/api/v1/metadata/sources/metron_api" + saved = await authenticated_client.put( + url, json=policy(credential="synthetic-source-token"), headers=csrf(authenticated_client) + ) + assert saved.status_code == 200 + assert saved.json()["credential_configured"] and saved.json()["revision"] == 1 + assert "synthetic-source-token" not in saved.text + read = await authenticated_client.get("/api/v1/metadata/sources") + assert read.status_code == 200 and len(read.json()) == 5 + assert "credential_secret" not in read.text and "synthetic-source-token" not in read.text + stale = await authenticated_client.put( + url, json=policy(priority=1), headers=csrf(authenticated_client) + ) + assert stale.status_code == 409 + async with sec_db() as session: + stored = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "metron_api") + ) + assert stored.credential_secret.startswith("enc:") and stored.priority == 5 + + +async def test_source_settings_require_operator_and_csrf( + authenticated_client, unauthenticated_client, sec_api_key +): + url = "/api/v1/metadata/sources/metron_api" + assert (await unauthenticated_client.get("/api/v1/metadata/sources")).status_code in {401, 403} + assert ( + await unauthenticated_client.put(url, json=policy(), headers={"X-API-Key": sec_api_key}) + ).status_code in {401, 403} + assert (await authenticated_client.put(url, json=policy())).status_code == 403 + assert (await authenticated_client.post(url + "/test")).status_code == 403 + + +async def test_feature_flag_blocks_activation_and_connection_test(authenticated_client): + url = "/api/v1/metadata/sources/gcd_api_v2" + response = await authenticated_client.put( + url, json=policy(), headers=csrf(authenticated_client) + ) + assert response.status_code == 400 + response = await authenticated_client.post(url + "/test", headers=csrf(authenticated_client)) + assert response.status_code == 200 + assert response.json()["outcome"]["status"] == "feature_disabled" + + +async def test_live_catalog_search_api_keeps_per_source_outcomes( + authenticated_client, monkeypatch, tmp_path +): + from pullbox.providers.metadata import sources + + monkeypatch.setattr(sources, "get_catalog_reader", lambda: installed_reader_cached) + installed_reader_cached = installed_reader(tmp_path) + result = await authenticated_client.post( + "/api/v1/metadata/search", + json={"query": "Dark Knight", "sources": ["comicvine_local", "metron_api"]}, + headers=csrf(authenticated_client), + ) + assert result.status_code == 200 + assert result.json()["results"][0]["external_id"] == "10" + assert [item["status"] for item in result.json()["sources"]] == ["ok", "disabled"] + assert result.json()["sources"][0]["total"] is None + + +async def test_search_releases_transaction_before_provider_work(authenticated_client, monkeypatch): + from pullbox.api.v1 import metadata_sources as routes + from pullbox.schemas.metadata_sources import SeriesDiscoveryRead + + original = routes.load_source_runtime + held = [] + + async def load(session, **kwargs): + held.append(session) + return await original(session, **kwargs) + + async def discover(self, query): + assert held and not held[0].in_transaction() + return SeriesDiscoveryRead(results=[], sources=[]) + + monkeypatch.setattr(routes, "load_source_runtime", load) + monkeypatch.setattr(routes.MetadataSourceRegistry, "discover", discover) + response = await authenticated_client.post( + "/api/v1/metadata/search", json={"query": "test"}, headers=csrf(authenticated_client) + ) + assert response.status_code == 200 + + +async def test_health_persists_only_safe_status( + authenticated_client, monkeypatch, tmp_path, sec_db +): + from pullbox.providers.metadata import sources + + reader = installed_reader(tmp_path) + monkeypatch.setattr(sources, "get_catalog_reader", lambda: reader) + url = "/api/v1/metadata/sources/comicvine_local" + assert ( + await authenticated_client.put(url, json=policy(), headers=csrf(authenticated_client)) + ).status_code == 200 + result = await authenticated_client.post(url + "/test", headers=csrf(authenticated_client)) + assert result.status_code == 200 and result.json()["recorded"] is True + assert result.json()["outcome"]["status"] == "ok" + async with sec_db() as session: + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "comicvine_local") + ) + assert row.last_status == "ok" and row.last_tested_at and row.last_success_at + + +async def test_search_requires_auth_and_csrf_and_rejects_invalid_paging( + authenticated_client, unauthenticated_client +): + url = "/api/v1/metadata/search" + assert (await unauthenticated_client.post(url, json={"query": "x"})).status_code in {401, 403} + assert (await authenticated_client.post(url, json={"query": "x"})).status_code == 403 + response = await authenticated_client.post( + url, + json={"query": "x", "offsets": {"comicvine_api": 1}}, + headers=csrf(authenticated_client), + ) + assert response.status_code == 422 diff --git a/tests/integration/metadata_identity/test_source_configuration.py b/tests/integration/metadata_identity/test_source_configuration.py index 9e5777c9..7f1f6a4e 100644 --- a/tests/integration/metadata_identity/test_source_configuration.py +++ b/tests/integration/metadata_identity/test_source_configuration.py @@ -107,3 +107,132 @@ async def save(): results = await asyncio.wait_for(asyncio.gather(save(), save()), 15) assert results.count(None) == 1 assert next(item for item in results if item is not None).revision == 1 + + +async def test_health_receipt_cannot_overwrite_new_settings_or_newer_probe(identity_probe_db): + from datetime import UTC, datetime, timedelta + + from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus + from pullbox.services.metadata_sources import record_source_health + + _, factory, _ = identity_probe_db + source = MetadataSource.METRON_API + now = datetime.now(UTC) + success = SourceOutcome(source=source, status=SourceStatus.OK) + async with factory.begin() as session: + saved = await save_source_policy(session, source, policy()) + assert await record_source_health(session, source, saved.revision, success, now) + assert not await record_source_health( + session, source, saved.revision, success, now - timedelta(seconds=1) + ) + async with factory.begin() as session: + await save_source_policy( + session, source, SourcePolicyWrite(revision=1, enabled=False, priority=5) + ) + assert not await record_source_health( + session, source, 1, success, now + timedelta(seconds=1) + ) + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == source.value) + ) + assert row.last_status is None and row.last_tested_at is None + + +async def test_secret_retention_clear_and_flagged_runtime_never_decrypts( + identity_probe_db, monkeypatch +): + from pullbox.schemas.metadata_sources import SourceStatus + from pullbox.services import metadata_sources as module + + _, factory, _ = identity_probe_db + async with factory.begin() as session: + source = MetadataSource.METRON_API + await save_source_policy(session, source, policy(credential=SecretStr("synthetic-token"))) + await save_source_policy( + session, source, SourcePolicyWrite(revision=1, enabled=False, priority=5) + ) + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == source.value) + ) + assert row.credential_secret.startswith("enc:") + await save_source_policy( + session, + MetadataSource.GCD_API_V2, + policy(credential=SecretStr("gated-token")), + gcd_api_enabled=True, + ) + + def forbidden(_): + raise AssertionError("Disabled sources must not decrypt credentials") + + monkeypatch.setattr(module, "decrypt_secret", forbidden) + async with factory() as session: + states = { + item.policy.source: item + for item in await module.load_source_runtime(session, gcd_api_enabled=False) + } + assert states[source].unavailable == SourceStatus.DISABLED + assert states[MetadataSource.GCD_API_V2].unavailable == SourceStatus.FEATURE_DISABLED + async with factory.begin() as session: + result = await save_source_policy( + session, + source, + SourcePolicyWrite(revision=2, enabled=False, priority=5, clear_credential=True), + ) + assert not result.credential_configured + + +async def test_corrupt_configuration_is_isolated(identity_probe_db): + from pullbox.schemas.metadata_sources import SourceStatus + + _, factory, _ = identity_probe_db + async with factory.begin() as session: + await save_source_policy(session, MetadataSource.METRON_API, policy()) + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "metron_api") + ) + row.settings = {"unknown": "not a valid setting"} + async with factory() as session: + results = {item.source: item for item in await read_source_policies(session)} + assert ( + results[MetadataSource.METRON_API].configuration_status == SourceStatus.INVALID_CONFIG + ) + assert results[MetadataSource.COMICVINE_LOCAL].enabled + + +async def test_actual_source_migration_roundtrip_preserves_canonical_credentials(identity_probe_db): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import MetaData + + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + await save_source_policy( + session, MetadataSource.COMICVINE_API, policy(credential=SecretStr("canonical-token")) + ) + async with engine.begin() as connection: + + def migrate(sync): + migration = _revision("s0m1n2o3p456_add_metadata_source_configuration", sync) + migration.downgrade() + migration.upgrade() + expected = MetaData() + MetadataSourceConfig.__table__.to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, name, type_, reflected, compare_to: ( + (name == "metadata_source_configs") if type_ == "table" else True + ) + }, + ) + assert compare_metadata(context, expected) == [] + + await connection.run_sync(migrate) + async with factory() as session: + policies = await read_source_policies(session) + assert len(list(await session.scalars(select(MetadataSourceConfig)))) == 5 + assert all(item.revision == 1 for item in policies) + assert await get_comicvine_api_key(session) == "canonical-token" diff --git a/tests/unit/test_metadata_discovery.py b/tests/unit/test_metadata_discovery.py new file mode 100644 index 00000000..079cbc5f --- /dev/null +++ b/tests/unit/test_metadata_discovery.py @@ -0,0 +1,250 @@ +"""Executable-source orchestration is bounded, honest, and identity-specific.""" + +import asyncio + +import pytest +from pydantic import ValidationError + +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.schemas.metadata_sources import ( + ProviderSeriesRead, + SeriesDiscoveryQuery, + SourceCapability, + SourceStatus, +) +from pullbox.services.metadata_discovery import ( + MetadataSourceError, + MetadataSourceRegistry, + SourcePage, + SourceRegistration, +) +from pullbox.services.metadata_sources import SourceRuntime, default_policy + + +def runtime(source, **updates): + return SourceRuntime(default_policy(source).model_copy(update={"enabled": True, **updates})) + + +def row(source, external_id="42", title="Same title"): + return ProviderSeriesRead( + source=source, + identity_namespace=source.identity_namespace, + external_id=external_id, + title=title, + ) + + +class Adapter: + def __init__(self, source, *, rows=None, error=None, wait=None): + self.source, self.rows, self.error, self.wait = source, rows, error, wait + self.calls = [] + self.closed = 0 + self.started = asyncio.Event() + + async def search(self, query, offset): + self.calls.append(offset) + self.started.set() + if self.wait: + await self.wait.wait() + if self.error: + raise self.error + return SourcePage( + self.rows if self.rows is not None else [row(self.source)], + 50, + offset + query.limit_per_source, + ) + + async def check(self): + if self.error: + raise self.error + + async def close(self): + self.closed += 1 + + +def registration(adapter, calls=None, capabilities=None): + def build(_): + if calls is not None: + calls.append(adapter.source) + return adapter + + return SourceRegistration( + frozenset(capabilities if capabilities is not None else [SourceCapability.SERIES_SEARCH]), + build, + ) + + +async def test_interactive_fanout_groups_exact_identity_not_title_and_preserves_cursors(): + local, api, metron = Source.COMICVINE_LOCAL, Source.COMICVINE_API, Source.METRON_API + adapters = {source: Adapter(source) for source in (local, api, metron)} + registry = MetadataSourceRegistry( + [runtime(local), runtime(api), runtime(metron)], + factories={source: registration(adapter) for source, adapter in adapters.items()}, + ) + result = await registry.discover(SeriesDiscoveryQuery(query="same", offsets={api: 20})) + assert [(item.source, item.external_id) for item in result.results] == [ + (local, "42"), + (metron, "42"), + ] + assert result.results[0].also_from == [api] + assert [item.next_offset for item in result.sources] == [20, 40, 20] + assert all(adapter.closed == 1 for adapter in adapters.values()) + assert adapters[api].calls == [20] + + +async def test_automatic_cascade_is_lazy_and_uses_domain_priority(): + api, local = Source.COMICVINE_API, Source.COMICVINE_LOCAL + calls = [] + registry = MetadataSourceRegistry( + [runtime(local), runtime(api, domain_priorities={"core": 1})], + factories={source: registration(Adapter(source), calls) for source in (local, api)}, + ) + result = await registry.discover( + SeriesDiscoveryQuery(query="same", mode="automatic"), + satisfied_by=lambda page: any(item.external_id == "42" for item in page.results), + ) + assert calls == [api] + assert result.results[0].source == api + assert [(item.source, item.status.value) for item in result.sources] == [ + (api, "ok"), + (local, "not_queried"), + ] + + +@pytest.mark.parametrize( + "error,status", + [ + (MetadataSourceError(SourceStatus.RATE_LIMITED, 90), SourceStatus.RATE_LIMITED), + (RuntimeError("synthetic-private-token"), SourceStatus.UNAVAILABLE), + ], +) +async def test_partial_failure_is_not_empty_or_exposed_and_cascade_continues(error, status): + local, api = Source.COMICVINE_LOCAL, Source.COMICVINE_API + first, second = Adapter(local, error=error), Adapter(api) + registry = MetadataSourceRegistry( + [runtime(local), runtime(api)], + factories={local: registration(first), api: registration(second)}, + ) + result = await registry.discover(SeriesDiscoveryQuery(query="same", mode="automatic")) + assert result.sources[0].status == status + assert result.sources[0].retry_after_seconds == ( + 90 if status == SourceStatus.RATE_LIMITED else None + ) + assert result.results[0].source == api + assert "synthetic-private-token" not in result.model_dump_json() + assert first.closed == second.closed == 1 + + +async def test_disabled_flagged_and_incapable_sources_never_construct(): + calls = [] + registry = MetadataSourceRegistry( + [ + runtime(Source.COMICVINE_API, enabled=False), + runtime(Source.GCD_API_V2), + runtime(Source.METRON_API), + runtime(Source.GCD_LOCAL), + ], + factories={ + source: registration(Adapter(source), calls, capabilities=[]) for source in Source + }, + ) + result = await registry.discover(SeriesDiscoveryQuery(query="same")) + assert calls == [] + assert {item.source: item.status for item in result.sources} == { + Source.COMICVINE_API: SourceStatus.DISABLED, + Source.GCD_API_V2: SourceStatus.FEATURE_DISABLED, + Source.METRON_API: SourceStatus.UNSUPPORTED, + Source.GCD_LOCAL: SourceStatus.UNSUPPORTED, + } + + +async def test_deadline_retains_fast_results_and_closes_slow_adapter(): + local, api = Source.COMICVINE_LOCAL, Source.COMICVINE_API + slow = Adapter(api, wait=asyncio.Event()) + registry = MetadataSourceRegistry( + [runtime(local), runtime(api)], + factories={local: registration(Adapter(local)), api: registration(slow)}, + per_source_timeout=0.03, + total_timeout=0.05, + ) + result = await asyncio.wait_for(registry.discover(SeriesDiscoveryQuery(query="same")), 1) + assert len(result.results) == 1 + assert result.sources[1].status == SourceStatus.TIMEOUT + assert slow.closed == 1 + + +async def test_cancellation_drains_children_and_closes_adapters(): + source = Source.COMICVINE_API + adapter = Adapter(source, wait=asyncio.Event()) + registry = MetadataSourceRegistry([runtime(source)], factories={source: registration(adapter)}) + task = asyncio.create_task(registry.discover(SeriesDiscoveryQuery(query="same"))) + await asyncio.sleep(0.01) + assert adapter.started.is_set() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert adapter.closed == 1 + + +async def test_factory_failure_does_not_hide_other_sources(): + def fail(_): + raise ValueError("private settings") + + local, api = Source.COMICVINE_LOCAL, Source.COMICVINE_API + registry = MetadataSourceRegistry( + [runtime(local), runtime(api)], + factories={ + local: SourceRegistration(frozenset([SourceCapability.SERIES_SEARCH]), fail), + api: registration(Adapter(api)), + }, + ) + result = await registry.discover(SeriesDiscoveryQuery(query="same")) + assert result.sources[0].status == SourceStatus.UNAVAILABLE + assert len(result.results) == 1 + + +async def test_automatic_search_does_not_assume_candidates_satisfy_identity(): + local, api = Source.COMICVINE_LOCAL, Source.COMICVINE_API + calls = [] + registry = MetadataSourceRegistry( + [runtime(local), runtime(api)], + factories={source: registration(Adapter(source), calls) for source in (local, api)}, + ) + result = await registry.discover( + SeriesDiscoveryQuery(query="unrelated title", mode="automatic") + ) + assert calls == [local, api] + assert all(item.status == SourceStatus.OK for item in result.sources) + + +async def test_interactive_fanout_obeys_concurrency_and_total_deadline(): + calls = [] + source_list = [Source.COMICVINE_LOCAL, Source.COMICVINE_API, Source.METRON_API] + adapters = {source: Adapter(source, wait=asyncio.Event()) for source in source_list} + registry = MetadataSourceRegistry( + [runtime(source) for source in source_list], + factories={source: registration(adapter, calls) for source, adapter in adapters.items()}, + per_source_timeout=0.2, + total_timeout=0.03, + concurrency=1, + ) + result = await asyncio.wait_for(registry.discover(SeriesDiscoveryQuery(query="same")), 1) + assert calls == [Source.COMICVINE_LOCAL] + assert all(item.status == SourceStatus.TIMEOUT for item in result.sources) + assert adapters[Source.COMICVINE_LOCAL].closed == 1 + + +@pytest.mark.parametrize( + "data", + [ + {"query": " "}, + {"offsets": {"comicvine_api": -1}}, + {"offsets": {"comicvine_api": 10001}}, + {"offsets": {"comicvine_api": 1}}, + {"sources": ["comicvine_api", "comicvine_api"]}, + {"sources": ["comicvine_local"], "offsets": {"comicvine_api": 20}}, + ], +) +def test_discovery_rejects_ambiguous_or_unbounded_requests(data): + with pytest.raises(ValidationError): + SeriesDiscoveryQuery(**{"query": "test", **data}) diff --git a/tests/unit/test_metadata_source_adapters.py b/tests/unit/test_metadata_source_adapters.py new file mode 100644 index 00000000..a621870e --- /dev/null +++ b/tests/unit/test_metadata_source_adapters.py @@ -0,0 +1,277 @@ +"""Actual ComicVine wire/SQLite adapters, without external provider traffic.""" + +import asyncio +import threading +from unittest.mock import AsyncMock + +import httpx +import pytest + +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.providers.metadata.comicvine import ComicVineProvider +from pullbox.providers.metadata.sources import ( + ComicVineApiSource, + ComicVineLocalSource, + comicvine_sources, +) +from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery, SourceCapability, SourceStatus +from pullbox.services.catalog.reader import CatalogReader +from pullbox.services.metadata_discovery import ( + MetadataSourceError, + MetadataSourceRegistry, + SourceRegistration, +) +from pullbox.services.metadata_sources import SourceRuntime, default_policy +from tests.unit.test_catalog_reader import installed_reader + + +async def api_adapter(handler): + provider = ComicVineProvider("synthetic-source-contract", rate_limit=999999, burst_limit=10) + await provider.close() + provider._client = httpx.AsyncClient( + base_url="https://comicvine.gamespot.com/api", + timeout=1, + transport=httpx.MockTransport(handler), + ) + return ComicVineApiSource(provider) + + +async def test_api_adapter_uses_real_page_and_normalizes_identity_not_urls(): + calls = [] + + def handle(request): + calls.append(request) + return httpx.Response( + 200, + json={ + "status_code": 1, + "number_of_total_results": 100, + "results": [ + { + "id": "0042", + "name": "Batman", + "start_year": "2024", + "publisher": {"name": "DC"}, + "image": {"medium_url": "javascript:bad"}, + "site_detail_url": "https://unrelated.example/4050-999/", + } + ], + }, + ) + + adapter = await api_adapter(handle) + try: + result = await adapter.search(SeriesDiscoveryQuery(query="Batman", limit_per_source=10), 20) + assert result.results[0].external_id == "42" + assert result.results[0].resource_url == "https://comicvine.gamespot.com/volume/4050-42/" + assert result.results[0].image_url is None + assert result.total == 100 and result.next_offset == 30 + assert calls[0].url.params["page"] == "3" + assert calls[0].url.params["limit"] == "10" + finally: + await adapter.close() + assert adapter.provider._client.is_closed + + +@pytest.mark.parametrize( + "code,status", [(100, SourceStatus.AUTHENTICATION_FAILED), (107, SourceStatus.RATE_LIMITED)] +) +async def test_api_error_is_typed_not_empty(code, status): + adapter = await api_adapter(lambda request: httpx.Response(200, json={"status_code": code})) + # Each case needs independent cooldown state, as the real provider intentionally shares it. + adapter.provider._api_key = f"synthetic-error-{code}" + try: + with pytest.raises(MetadataSourceError) as raised: + await adapter.search(SeriesDiscoveryQuery(query="Batman"), 0) + assert raised.value.status == status + if code == 107: + assert raised.value.retry_after_seconds > 0 + finally: + await adapter.close() + + +async def test_api_transport_timeout_has_a_typed_outcome(): + def handle(request): + raise httpx.ReadTimeout("synthetic-token", request=request) + + adapter = await api_adapter(handle) + try: + with pytest.raises(MetadataSourceError) as raised: + await adapter.search(SeriesDiscoveryQuery(query="Batman"), 0) + assert raised.value.status == SourceStatus.TIMEOUT + assert "synthetic-token" not in str(raised.value) + finally: + await adapter.close() + + +async def test_malformed_response_is_not_reported_as_empty(): + adapter = await api_adapter( + lambda request: httpx.Response(200, json={"status_code": 1, "results": "not rows"}) + ) + try: + with pytest.raises(MetadataSourceError) as raised: + await adapter.search(SeriesDiscoveryQuery(query="Batman"), 0) + assert raised.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await adapter.close() + + +@pytest.mark.parametrize( + "payload", + [ + {"status_code": 1}, + {"status_code": 1, "results": []}, + {"status_code": 1, "results": [], "number_of_total_results": "invalid"}, + {"status_code": 1, "results": [], "number_of_total_results": True}, + ], +) +async def test_incomplete_success_envelope_is_not_an_empty_search(payload): + adapter = await api_adapter(lambda request: httpx.Response(200, json=payload)) + try: + with pytest.raises(MetadataSourceError) as raised: + await adapter.search(SeriesDiscoveryQuery(query="Batman"), 0) + assert raised.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await adapter.close() + + +async def test_bad_identity_does_not_drop_valid_sibling(): + adapter = await api_adapter( + lambda request: httpx.Response( + 200, + json={ + "status_code": 1, + "number_of_total_results": 2, + "results": [{"id": 0, "name": "Bad"}, {"id": 42, "name": "Good"}], + }, + ) + ) + try: + page = await adapter.search(SeriesDiscoveryQuery(query="test"), 0) + assert [item.external_id for item in page.results] == ["42"] + assert page.rejected_results == 1 and page.next_offset is None + finally: + await adapter.close() + + +async def test_local_adapter_reads_real_catalog_without_fake_total(tmp_path): + adapter = ComicVineLocalSource(installed_reader(tmp_path)) + page = await adapter.search(SeriesDiscoveryQuery(query="Dark Knight", limit_per_source=1), 0) + assert len(page.results) == 1 and page.results[0].external_id == "10" + assert page.total is None and page.next_offset is None + await adapter.check() + + +async def test_local_page_uses_lookahead_and_marks_offset_limit(tmp_path, monkeypatch): + reader = installed_reader(tmp_path) + found = await reader.search("Batman") + mock = AsyncMock(return_value=found * 3) + monkeypatch.setattr(reader, "search", mock) + adapter = ComicVineLocalSource(reader) + page = await adapter.search(SeriesDiscoveryQuery(query="Batman", limit_per_source=2), 10000) + assert len(page.results) == 2 and page.truncated and page.next_offset is None + mock.assert_awaited_once_with("Batman", None, 3, 10000) + + +@pytest.mark.parametrize("corrupt", [False, True]) +async def test_local_health_does_not_treat_pointer_existence_as_healthy(tmp_path, corrupt): + reader = installed_reader(tmp_path) if corrupt else CatalogReader(tmp_path / "missing") + if corrupt: + (reader.root / "bases/20260913T050000Z.db").write_bytes(b"not sqlite") + with pytest.raises(MetadataSourceError) as raised: + await ComicVineLocalSource(reader).check() + assert raised.value.status == ( + SourceStatus.UNAVAILABLE if corrupt else SourceStatus.UNCONFIGURED + ) + + +def test_registry_declares_only_working_capabilities_and_requires_api_credentials(): + sources = comicvine_sources() + assert set(sources) == {Source.COMICVINE_LOCAL, Source.COMICVINE_API} + assert SourceCapability.SERIES_SEARCH in sources[Source.COMICVINE_API].capabilities + assert SourceCapability.STORY_ARC_SEARCH not in sources[Source.COMICVINE_API].capabilities + with pytest.raises(MetadataSourceError) as raised: + sources[Source.COMICVINE_API].factory(SourceRuntime(default_policy(Source.COMICVINE_API))) + assert raised.value.status == SourceStatus.UNCONFIGURED + + +async def test_registry_timeout_does_not_wait_for_owned_catalog_disk_read(tmp_path, monkeypatch): + reader = installed_reader(tmp_path) + entered, release = threading.Event(), threading.Event() + original = reader._query + + def blocked(*args): + entered.set() + release.wait(3) + return original(*args) + + monkeypatch.setattr(reader, "_query", blocked) + registry = MetadataSourceRegistry( + [SourceRuntime(default_policy(Source.COMICVINE_LOCAL))], + factories={ + Source.COMICVINE_LOCAL: SourceRegistration( + frozenset([SourceCapability.SERIES_SEARCH]), lambda _: ComicVineLocalSource(reader) + ) + }, + per_source_timeout=0.25, + total_timeout=2, + ) + task = asyncio.create_task(registry.discover(SeriesDiscoveryQuery(query="Batman"))) + try: + assert await asyncio.to_thread(entered.wait, 1) + done, _ = await asyncio.wait({task}, timeout=0.75) + assert task in done, "A catalog read must not hold the request past its deadline" + assert task.result().sources[0].status == SourceStatus.TIMEOUT + finally: + release.set() + await task + await asyncio.gather( + *(item for item in asyncio.all_tasks() if item.get_name() == "metadata-catalog-read"), + return_exceptions=True, + ) + + +async def test_timed_out_catalog_reads_are_retained_and_globally_bounded(tmp_path, monkeypatch): + reader = installed_reader(tmp_path) + release = threading.Event() + original = reader._query + calls = [] + + def blocked(*args): + calls.append(args) + release.wait(3) + return original(*args) + + monkeypatch.setattr(reader, "_query", blocked) + registry = MetadataSourceRegistry( + [SourceRuntime(default_policy(Source.COMICVINE_LOCAL))], + factories={ + Source.COMICVINE_LOCAL: SourceRegistration( + frozenset([SourceCapability.SERIES_SEARCH]), lambda _: ComicVineLocalSource(reader) + ) + }, + per_source_timeout=0.25, + total_timeout=2, + ) + tasks = [ + asyncio.create_task(registry.discover(SeriesDiscoveryQuery(query="Batman"))) + for _ in range(2) + ] + try: + done, _ = await asyncio.wait(tasks, timeout=0.75) + assert len(done) == 2 + assert all(task.result().sources[0].status == SourceStatus.TIMEOUT for task in tasks) + again = await registry.discover(SeriesDiscoveryQuery(query="Batman")) + assert again.sources[0].status == SourceStatus.UNAVAILABLE + assert again.sources[0].retry_after_seconds == 1 + assert len(calls) == 2 + finally: + release.set() + await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather( + *(item for item in asyncio.all_tasks() if item.get_name() == "metadata-catalog-read"), + return_exceptions=True, + ) + assert (await registry.discover(SeriesDiscoveryQuery(query="Batman"))).sources[ + 0 + ].status == SourceStatus.OK From 105daf1a9149dbd2e4ce2796e92671d891495177 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 03:29:38 -0700 Subject: [PATCH 20/97] feat(metadata): add source priority and health settings --- src/pullbox/api/v1/config.py | 38 +++- src/pullbox/api/v1/metadata_sources.py | 42 ++--- src/pullbox/schemas/metadata_sources.py | 22 +++ src/pullbox/services/metadata_discovery.py | 31 +++ src/pullbox/services/metadata_sources.py | 51 +++++ src/pullbox/ui/settings_routes.py | 10 + src/pullbox/ui/standalone_shell.py | 1 + src/pullbox/ui/static/js/metadata-sources.js | 169 +++++++++++++++++ src/pullbox/ui/templates/base.html | 1 + .../templates/partials/settings_metadata.html | 2 + .../partials/settings_metadata_sources.html | 79 ++++++++ tests/api/test_metadata_sources_api.py | 67 +++++++ tests/e2e/test_metadata_sources_settings.py | 178 ++++++++++++++++++ .../test_source_priorities.py | 134 +++++++++++++ tests/ui/test_metadata_sources_settings.py | 61 ++++++ tests/unit/test_config_api_branches.py | 2 +- tests/unit/test_standalone_shell.py | 3 +- 17 files changed, 862 insertions(+), 29 deletions(-) create mode 100644 src/pullbox/ui/static/js/metadata-sources.js create mode 100644 src/pullbox/ui/templates/partials/settings_metadata_sources.html create mode 100644 tests/e2e/test_metadata_sources_settings.py create mode 100644 tests/integration/metadata_identity/test_source_priorities.py create mode 100644 tests/ui/test_metadata_sources_settings.py diff --git a/src/pullbox/api/v1/config.py b/src/pullbox/api/v1/config.py index 5f55e597..59a2761b 100644 --- a/src/pullbox/api/v1/config.py +++ b/src/pullbox/api/v1/config.py @@ -3,7 +3,7 @@ from pathlib import Path import structlog -from fastapi import APIRouter, Query, Request +from fastapi import APIRouter, HTTPException, Query, Request from sqlalchemy import select from pullbox.api.deps import DbSession, InteractiveOperatorUser @@ -769,13 +769,45 @@ async def save_comicvine_key( body: dict[str, str], ) -> dict[str, object]: """Save (encrypted) the ComicVine API key to the database.""" - from pullbox.core.comicvine_key import obfuscate_api_key, save_comicvine_api_key + from pydantic import SecretStr + + from pullbox.core.comicvine_key import obfuscate_api_key + from pullbox.core.metadata_identity import MetadataSource + from pullbox.schemas.metadata_sources import SourcePolicyWrite + from pullbox.services.metadata_sources import ( + SourceConfigurationConflictError, + read_source_policies, + save_source_policy, + ) api_key = body.get("api_key", "").strip() if not api_key: return {"saved": False, "message": "No API key provided."} - await save_comicvine_api_key(session, api_key) + policy = next( + item + for item in await read_source_policies(session) + if item.source is MetadataSource.COMICVINE_API + ) + if policy.configuration_status is not None: + raise HTTPException(409, "Source configuration needs attention before saving a new key") + try: + await save_source_policy( + session, + MetadataSource.COMICVINE_API, + SourcePolicyWrite( + revision=policy.revision, + enabled=policy.enabled, + priority=policy.priority, + domain_priorities=policy.domain_priorities, + settings=policy.settings, + credential=SecretStr(api_key), + ), + ) + except SourceConfigurationConflictError as exc: + raise HTTPException(409, str(exc)) from exc + except ValueError as exc: + raise HTTPException(400, "Could not save the provider token; check its format") from exc return { "saved": True, "message": "API key saved.", diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py index 7c456437..49547a26 100644 --- a/src/pullbox/api/v1/metadata_sources.py +++ b/src/pullbox/api/v1/metadata_sources.py @@ -7,23 +7,23 @@ from pullbox.api.deps import AuthenticatedUser, DbSession, InteractiveOperatorUser, Settings from pullbox.core.metadata_identity import MetadataSource -from pullbox.providers.metadata.sources import comicvine_sources from pullbox.schemas.metadata_sources import ( SeriesDiscoveryQuery, SeriesDiscoveryRead, SourceDescriptor, SourcePolicyRead, SourcePolicyWrite, - SourceStatus, + SourcePriorityWrite, SourceTestRead, ) -from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies from pullbox.services.metadata_sources import ( SourceConfigurationConflictError, load_source_runtime, read_source_policies, record_source_health, save_source_policy, + save_source_priorities, ) router = APIRouter(prefix="/metadata", tags=["metadata"]) @@ -34,27 +34,21 @@ async def sources( session: DbSession, _user: InteractiveOperatorUser, settings: Settings ) -> list[SourceDescriptor]: - registrations = comicvine_sources() - result = [] - for policy in await read_source_policies(session): - registration = registrations.get(policy.source) - availability = policy.configuration_status - if policy.source is MetadataSource.GCD_API_V2 and not settings.metadata_gcd_api_v2_enabled: - availability = SourceStatus.FEATURE_DISABLED - elif not policy.enabled: - availability = availability or SourceStatus.DISABLED - elif registration is None: - availability = SourceStatus.NOT_IMPLEMENTED - elif policy.source is MetadataSource.COMICVINE_API and not policy.credential_configured: - availability = SourceStatus.UNCONFIGURED - result.append( - SourceDescriptor( - **policy.model_dump(), - capabilities=sorted(registration.capabilities) if registration else [], - availability=availability, - ) - ) - return result + return describe_source_policies( + await read_source_policies(session), gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + ) + + +@router.put("/priorities", response_model=list[SourceDescriptor]) +async def save_priorities( + body: SourcePriorityWrite, session: DbSession, user: InteractiveOperatorUser, settings: Settings +) -> list[SourceDescriptor]: + try: + policies = await save_source_priorities(session, body) + except SourceConfigurationConflictError as exc: + raise HTTPException(409, str(exc)) from exc + logger.info("metadata_source_priorities_updated", user_id=user.id) + return describe_source_policies(policies, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) @router.put("/sources/{source}", response_model=SourcePolicyRead) diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index 21974df6..baf87c23 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -77,6 +77,28 @@ class SourcePolicyRead(BaseModel): configuration_status: SourceStatus | None = None +class SourcePriorityWrite(BaseModel): + model_config = ConfigDict(extra="forbid") + order: list[MetadataSource] = Field(min_length=5, max_length=5) + revisions: dict[MetadataSource, Annotated[int, Field(ge=0, lt=2**63, strict=True)]] + domain_orders: dict[MetadataDomain, list[MetadataSource]] = Field(default_factory=dict) + + @model_validator(mode="after") + def complete_orders(self) -> Self: + sources = set(MetadataSource) + if set(self.order) != sources or set(self.revisions) != sources: + raise ValueError("Include every metadata source exactly once with its saved revision") + for domain, order in self.domain_orders.items(): + allowed = sources - ( + {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2} + if domain is MetadataDomain.ARTWORK + else set() + ) + if len(order) != len(allowed) or set(order) != allowed: + raise ValueError("Include every eligible source exactly once in each domain order") + return self + + class SeriesDiscoveryQuery(BaseModel): model_config = ConfigDict(extra="forbid") query: str = Field(min_length=1, max_length=300) diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index 052d8f8c..f658aa14 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -15,7 +15,9 @@ SeriesDiscoveryQuery, SeriesDiscoveryRead, SourceCapability, + SourceDescriptor, SourceOutcome, + SourcePolicyRead, SourceStatus, ) @@ -27,6 +29,35 @@ logger = structlog.get_logger(__name__) +def describe_source_policies( + policies: Sequence[SourcePolicyRead], *, gcd_api_enabled: bool +) -> list[SourceDescriptor]: + """Expose capability/configuration snapshots without constructing clients.""" + from pullbox.providers.metadata.sources import comicvine_sources + + registrations = comicvine_sources() + result = [] + for policy in policies: + registration = registrations.get(policy.source) + availability = policy.configuration_status + if policy.source is MetadataSource.GCD_API_V2 and not gcd_api_enabled: + availability = SourceStatus.FEATURE_DISABLED + elif not policy.enabled: + availability = availability or SourceStatus.DISABLED + elif registration is None: + availability = SourceStatus.NOT_IMPLEMENTED + elif policy.source is MetadataSource.COMICVINE_API and not policy.credential_configured: + availability = SourceStatus.UNCONFIGURED + result.append( + SourceDescriptor( + **policy.model_dump(), + capabilities=sorted(registration.capabilities) if registration else [], + availability=availability, + ) + ) + return result + + @dataclass(frozen=True) class SourcePage: results: list[ProviderSeriesRead] diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py index 9d5a3323..acdd59a8 100644 --- a/src/pullbox/services/metadata_sources.py +++ b/src/pullbox/services/metadata_sources.py @@ -21,6 +21,7 @@ SourceOutcome, SourcePolicyRead, SourcePolicyWrite, + SourcePriorityWrite, SourceSettings, SourceStatus, ) @@ -255,3 +256,53 @@ async def record_source_health( .returning(MetadataSourceConfig.id) ) return result.scalar_one_or_none() is not None + + +async def save_source_priorities( + session: AsyncSession, body: SourcePriorityWrite +) -> list[SourcePolicyRead]: + """Save one revision-checked global and domain ordering transaction.""" + if session.get_bind().dialect.name == "sqlite": + await session.execute( + update(MetadataSourceConfig) + .where(false()) + .values(revision=MetadataSourceConfig.revision) + ) + try: + async with session.begin_nested(): + rows = { + row.source: row + for row in await session.scalars( + select(MetadataSourceConfig) + .order_by(MetadataSourceConfig.source) + .with_for_update() + .execution_options(populate_existing=True) + ) + } + for source, revision in body.revisions.items(): + row = rows.get(source.value) + if (row.revision if row else 0) != revision: + raise SourceConfigurationConflictError( + "Source settings changed; reload before saving" + ) + # Missing default rows follow the same lock order as existing rows. + for source in sorted(MetadataSource): + row = rows.get(source.value) + if row is None: + row = MetadataSourceConfig( + source=source.value, enabled=default_policy(source).enabled + ) + session.add(row) + row.priority = (body.order.index(source) + 1) * 10 + row.domain_priorities = { + domain.value: (order.index(source) + 1) * 10 + for domain, order in body.domain_orders.items() + if source in order + } + row.revision = body.revisions[source] + 1 + await session.flush() + except IntegrityError as exc: + raise SourceConfigurationConflictError( + "Source settings changed; reload before saving" + ) from exc + return await read_source_policies(session) diff --git a/src/pullbox/ui/settings_routes.py b/src/pullbox/ui/settings_routes.py index 3204092c..472eebac 100644 --- a/src/pullbox/ui/settings_routes.py +++ b/src/pullbox/ui/settings_routes.py @@ -280,6 +280,16 @@ async def load_settings_tab(request: Request, session: DbSession, tab: str) -> d active_key = await get_comicvine_api_key(session) ctx["has_comicvine_key"] = bool(active_key) ctx["obfuscated_key"] = obfuscate_api_key(active_key) + from pullbox.services.metadata_discovery import describe_source_policies + from pullbox.services.metadata_sources import read_source_policies + + ctx["metadata_source_seed"] = [ + policy.model_dump(mode="json") + for policy in describe_source_policies( + await read_source_policies(session), + gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled, + ) + ] elif tab == "search": result = await session.execute(select(SystemConfig).order_by(SystemConfig.key)) ctx["configs"] = {c.key: c.value for c in result.scalars().all()} diff --git a/src/pullbox/ui/standalone_shell.py b/src/pullbox/ui/standalone_shell.py index 411e0d81..1ad13c1d 100644 --- a/src/pullbox/ui/standalone_shell.py +++ b/src/pullbox/ui/standalone_shell.py @@ -22,6 +22,7 @@ _STATIC_DIR / "js" / "story-arc-preview.js", _STATIC_DIR / "js" / "story-arc-detail.js", _STATIC_DIR / "js" / "series-rescan.js", + _STATIC_DIR / "js" / "metadata-sources.js", ) diff --git a/src/pullbox/ui/static/js/metadata-sources.js b/src/pullbox/ui/static/js/metadata-sources.js new file mode 100644 index 00000000..cd64e910 --- /dev/null +++ b/src/pullbox/ui/static/js/metadata-sources.js @@ -0,0 +1,169 @@ +function metadataSourceSettings(seed, csrf) { + const clone = value => JSON.parse(JSON.stringify(value)); + const labels = { + comicvine_local: 'ComicVine Local Catalog', comicvine_api: 'ComicVine API', + metron_api: 'Metron', gcd_local: 'GCD Local Database', gcd_api_v2: 'GCD API v2', + }; + return { + sources: clone(seed), savedSources: clone(seed), order: [], domainOrders: {}, + domains: {core: 'Core metadata', issues: 'Issue catalogs', artwork: 'Artwork', story_arcs: 'Story arcs'}, + labels, saving: false, testing: null, message: '', error: '', conflict: false, + healthMessage: '', controllers: new Set(), alive: true, savedOrder: '', refreshing: false, + init() { this.accept(seed); }, + destroy() { this.alive = false; this.controllers.forEach(controller => controller.abort()); }, + eligible(domain) { + return this.order.filter(source => domain !== 'artwork' || !source.startsWith('gcd_')); + }, + accept(data) { + this.sources = clone(data); + this.savedSources = clone(data); + this.order = data.map(item => item.source); + this.domainOrders = {}; + for (const domain of Object.keys(this.domains)) { + if (data.some(item => Object.hasOwn(item.domain_priorities, domain))) { + this.domainOrders[domain] = this.eligible(domain).sort((a, b) => { + const left = data.find(item => item.source === a); + const right = data.find(item => item.source === b); + return (left.domain_priorities[domain] ?? left.priority) - + (right.domain_priorities[domain] ?? right.priority) || a.localeCompare(b); + }); + } + } + this.savedOrder = this.signature(); + }, + signature() { + return JSON.stringify({order: this.order, domain_orders: Object.fromEntries( + Object.keys(this.domains).filter(domain => this.domainOrders[domain]).map(domain => [domain, this.domainOrders[domain]]) + )}); + }, + get dirty() { + return this.signature() !== this.savedOrder; + }, + move(domain, index, direction) { + if (this.saving) return; + const order = domain === 'global' ? this.order : this.domainOrders[domain]; + const target = index + direction; + if (!order || target < 0 || target >= order.length) return; + const focused = document.activeElement; + const row = focused?.closest('[data-source-row]'); + [order[index], order[target]] = [order[target], order[index]]; + this.message = ''; + if (row && this.$root.contains(row)) { + this.$nextTick(() => { + const button = focused.disabled ? row.querySelector('button:not(:disabled)') : focused; + button?.focus({preventScroll: true}); + }); + } + }, + toggleDomain(domain, enabled) { + if (this.saving) return; + if (enabled) this.domainOrders[domain] = this.eligible(domain); + else delete this.domainOrders[domain]; + this.message = ''; + }, + reset() { + if (this.saving) return; + this.accept(this.savedSources); + this.message = ''; this.error = ''; this.conflict = false; + }, + async request(url, options = {}) { + const controller = new AbortController(); + this.controllers.add(controller); + const timeout = setTimeout(() => controller.abort(), 20000); + try { + const response = await fetch(url, { + ...options, signal: controller.signal, + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf}, + }); + const data = await response.json(); + return {ok: response.ok, status: response.status, data}; + } finally { + clearTimeout(timeout); + this.controllers.delete(controller); + } + }, + async save() { + if (this.saving || this.testing || this.refreshing || !this.dirty) return; + this.saving = true; this.error = ''; this.message = ''; this.conflict = false; + try { + const response = await this.request('/api/v1/metadata/priorities', { + method: 'PUT', body: JSON.stringify({ + order: this.order, domain_orders: this.domainOrders, + revisions: Object.fromEntries(this.savedSources.map(item => [item.source, item.revision])), + }), + }); + if (response.status === 409) { + this.conflict = true; + this.error = 'Settings changed in another session. Your draft is kept. Load saved priority before trying again.'; + return; + } + if (!response.ok) throw new Error('save'); + const data = response.data; + if (!this.alive) return; + this.accept(data); + this.message = 'Metadata priority saved.'; + } catch (_) { + if (this.alive) this.error = 'Could not save metadata priority. Your draft is kept; check the connection and retry.'; + } finally { this.saving = false; } + }, + async reload() { + if (this.saving || this.testing) return; + this.saving = true; + try { + const response = await this.request('/api/v1/metadata/sources'); + if (!response.ok) throw new Error('load'); + const data = response.data; + if (!this.alive) return; + this.accept(data); this.error = ''; this.conflict = false; this.message = ''; + } catch (_) { + if (this.alive) this.error = 'Could not load saved priority. Your draft is kept; check the connection and retry.'; + } finally { this.saving = false; } + }, + status(item) { + if (!item.capabilities.length && item.availability !== 'feature_disabled') return 'Not available in this build'; + const state = item.availability || item.last_status || 'not_checked'; + return { + ok: 'Ready', empty: 'Ready', disabled: 'Disabled', feature_disabled: 'Not released yet', + not_implemented: 'Not available in this build', unconfigured: 'Not configured', + invalid_configuration: 'Configuration needs attention', authentication_failed: 'Authentication required', + rate_limited: 'Rate limited', timeout: 'Timed out', unavailable: 'Unavailable', + incompatible_response: 'Unexpected provider response', unsupported: 'Not supported', + not_checked: 'Not checked yet', + }[state] || 'Needs attention'; + }, + canTest(item) { return item.enabled && item.capabilities.length > 0 && !item.availability; }, + async refreshHealth() { + if (this.refreshing) return; + this.refreshing = true; + try { + const response = await this.request('/api/v1/metadata/sources'); + if (!response.ok) throw new Error('load'); + if (this.alive) this.sources = response.data; + } catch (_) { + if (this.alive) this.healthMessage = 'Could not refresh source status. Reload Metadata settings to check the saved configuration.'; + } finally { this.refreshing = false; } + }, + async test(item) { + if (this.testing || this.saving || this.refreshing || !this.canTest(item)) return; + this.testing = item.source; + this.healthMessage = 'Checking ' + this.labels[item.source] + '...'; + try { + const response = await this.request('/api/v1/metadata/sources/' + item.source + '/test', {method: 'POST'}); + if (!response.ok) throw new Error('test'); + const data = response.data; + if (!this.alive) return; + if (!data.recorded) { + this.healthMessage = 'Settings changed during this check. Test the saved configuration again.'; + return; + } + item.last_status = data.outcome.status; + this.healthMessage = this.labels[item.source] + ': ' + this.status(item) + '.'; + if (data.outcome.retry_after_seconds != null) { + this.healthMessage += ' Retry in ' + data.outcome.retry_after_seconds + ' seconds.'; + } + } catch (_) { + if (this.alive) this.healthMessage = 'Could not check ' + this.labels[item.source] + '. Check the connection and retry.'; + } finally { this.testing = null; } + }, + }; +} diff --git a/src/pullbox/ui/templates/base.html b/src/pullbox/ui/templates/base.html index 3ff34742..f3cf0b28 100644 --- a/src/pullbox/ui/templates/base.html +++ b/src/pullbox/ui/templates/base.html @@ -984,6 +984,7 @@

+ diff --git a/src/pullbox/ui/templates/partials/settings_metadata.html b/src/pullbox/ui/templates/partials/settings_metadata.html index 67c69d71..2518938f 100644 --- a/src/pullbox/ui/templates/partials/settings_metadata.html +++ b/src/pullbox/ui/templates/partials/settings_metadata.html @@ -2,6 +2,7 @@ {% from "components/settings_shell.html" import field_note, inline_alert, section_card, settings_footer, settings_row, ui_icon %}
+ {% include "partials/settings_metadata_sources.html" %} {% call section_card( title="ComicVine access", eyebrow="API credentials", @@ -169,6 +170,7 @@ this.testResult = null; this.obfuscatedKey = data.obfuscated; this.apiKey = ''; + window.dispatchEvent(new CustomEvent('metadata-credentials-updated')); if (typeof showToast === 'function') showToast({ message: 'API key saved.', level: 'success' }); } else { if (typeof showToast === 'function') showToast({ message: data.message || 'Save failed.', level: 'error' }); diff --git a/src/pullbox/ui/templates/partials/settings_metadata_sources.html b/src/pullbox/ui/templates/partials/settings_metadata_sources.html new file mode 100644 index 00000000..1b38be14 --- /dev/null +++ b/src/pullbox/ui/templates/partials/settings_metadata_sources.html @@ -0,0 +1,79 @@ +{% from "components/settings_shell.html" import section_card, settings_footer, ui_icon %} +{% from "components/order_chevrons.html" import order_chevrons %} + +{% macro priority_list(domain) %} +
    + +
+{% endmacro %} + +
+{% call section_card(title="Metadata priority", title_style="plain", body_class="p-0") %} +
+

Put preferred sources first. Only enabled sources that support the requested information are used. Changing priority never changes a comic's identity.

+ {{ priority_list('global') }} +
+ {{ ui_icon('chevron-down') }} Advanced domain priorities +
+

Use the global order unless a type of metadata needs a different preference.

+ {% for domain, label in [('core', 'Core metadata'), ('issues', 'Issue catalogs'), ('artwork', 'Artwork'), ('story_arcs', 'Story arcs')] %} +
+ +
{{ priority_list(domain) }}
+
+ {% endfor %} +
+
+ +

+
+ {% call settings_footer(align="end") %} + + {% endcall %} +{% endcall %} + +
+{% call section_card(title="Metadata sources", title_style="plain", body_class="p-0") %} +

Connection checks use saved settings. A previous success does not guarantee a source is available now.

+
+ +

+
+{% endcall %} +
+
+ + diff --git a/tests/api/test_metadata_sources_api.py b/tests/api/test_metadata_sources_api.py index 4d96c18b..0bece03e 100644 --- a/tests/api/test_metadata_sources_api.py +++ b/tests/api/test_metadata_sources_api.py @@ -146,3 +146,70 @@ async def test_search_requires_auth_and_csrf_and_rejects_invalid_paging( headers=csrf(authenticated_client), ) assert response.status_code == 422 + + +async def test_priority_api_is_atomic_and_revision_checked(authenticated_client): + sources = (await authenticated_client.get("/api/v1/metadata/sources")).json() + payload = { + "order": list(reversed([item["source"] for item in sources])), + "revisions": {item["source"]: item["revision"] for item in sources}, + "domain_orders": {}, + } + url = "/api/v1/metadata/priorities" + result = await authenticated_client.put(url, json=payload, headers=csrf(authenticated_client)) + assert result.status_code == 200 + assert [item["source"] for item in result.json()] == payload["order"] + stale = await authenticated_client.put(url, json=payload, headers=csrf(authenticated_client)) + assert stale.status_code == 409 + assert (await authenticated_client.get("/api/v1/metadata/sources")).json() == result.json() + + +async def test_priority_api_requires_operator_and_csrf( + authenticated_client, unauthenticated_client, sec_api_key +): + url = "/api/v1/metadata/priorities" + assert (await authenticated_client.put(url, json={})).status_code == 403 + assert ( + await unauthenticated_client.put(url, json={}, headers={"X-API-Key": sec_api_key}) + ).status_code in {401, 403} + + +async def test_existing_key_save_invalidates_source_revision_and_old_health( + authenticated_client, sec_db +): + from datetime import UTC, datetime + + from pullbox.core.metadata_identity import MetadataSource + from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus + from pullbox.services.metadata_sources import record_source_health + + url = "/api/v1/metadata/sources/comicvine_api" + response = await authenticated_client.put( + url, + json=policy(enabled=False, priority=13, domain_priorities={"issues": 1}), + headers=csrf(authenticated_client), + ) + assert response.status_code == 200 + revision = response.json()["revision"] + now = datetime.now(UTC) + outcome = SourceOutcome(source=MetadataSource.COMICVINE_API, status=SourceStatus.OK) + async with sec_db.begin() as session: + assert await record_source_health( + session, MetadataSource.COMICVINE_API, revision, outcome, now + ) + saved = await authenticated_client.post( + "/api/v1/config/comicvine/save", + json={"api_key": "new-synthetic-key-for-revision"}, + headers=csrf(authenticated_client), + ) + assert saved.status_code == 200 and saved.json()["saved"] + result = (await authenticated_client.get("/api/v1/metadata/sources")).json() + source = next(item for item in result if item["source"] == "comicvine_api") + assert source["revision"] == revision + 1 + assert not source["enabled"] and source["priority"] == 13 + assert source["domain_priorities"] == {"issues": 1} + assert source["last_status"] is None and source["last_success_at"] is None + async with sec_db.begin() as session: + assert not await record_source_health( + session, MetadataSource.COMICVINE_API, revision, outcome, now + ) diff --git a/tests/e2e/test_metadata_sources_settings.py b/tests/e2e/test_metadata_sources_settings.py new file mode 100644 index 00000000..6aa549d0 --- /dev/null +++ b/tests/e2e/test_metadata_sources_settings.py @@ -0,0 +1,178 @@ +"""Provider priority uses stable keyed rows and the real authenticated API.""" + +import pytest +from playwright.sync_api import expect + +from tests.e2e.accessibility import assert_no_axe_violations +from tests.e2e.pages.settings import SettingsPage + +pytestmark = pytest.mark.e2e + + +def test_metadata_priorities_save_reload_and_domain_reset(authed_page, seeded_server): + page = authed_page + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + SettingsPage(page, seeded_server).goto("metadata") + card = page.get_by_test_id("metadata-source-priority") + expect(card).to_be_visible() + global_order = card.get_by_test_id("metadata-order-global") + rows = global_order.locator("[data-source-row]") + initial = rows.locator("[data-source-label]").all_text_contents() + assert len(initial) == 5 + down = global_order.locator('[data-order-direction="down"]') + down.first.press("Enter") + expected = [initial[1], initial[0], *initial[2:]] + expect(rows.locator("[data-source-label]")).to_have_text(expected) + expect( + global_order.get_by_role("button", name=f"Move {initial[0]} down", exact=True) + ).to_be_focused() + card.get_by_text("Advanced domain priorities", exact=True).click() + card.get_by_label("Use a separate order for Artwork").check() + artwork = card.get_by_test_id("metadata-order-artwork") + expect(artwork.locator("[data-source-row]")).to_have_count(3) + artwork.locator('[data-order-direction="down"]').first.click() + with page.expect_response("**/api/v1/metadata/priorities") as saved: + card.get_by_role("button", name="Save metadata priority", exact=True).click() + assert saved.value.status == 200 + expect(card.get_by_role("status")).to_contain_text("Metadata priority saved") + page.reload() + expect(rows.locator("[data-source-label]")).to_have_text(expected) + card.get_by_text("Advanced domain priorities", exact=True).click() + expect(card.get_by_label("Use a separate order for Artwork")).to_be_checked() + card.get_by_label("Use a separate order for Artwork").uncheck() + global_order.locator('[data-order-direction="up"]').nth(1).click() + with page.expect_response("**/api/v1/metadata/priorities") as saved: + card.get_by_role("button", name="Save metadata priority", exact=True).click() + assert saved.value.status == 200 + assert all(not row["domain_priorities"] for row in saved.value.json()) + assert not errors + + +def test_metadata_priority_conflict_keeps_draft_and_rows(authed_page, seeded_server): + page = authed_page + SettingsPage(page, seeded_server).goto("metadata") + card = page.get_by_test_id("metadata-source-priority") + expect(card).to_be_visible() + card.evaluate("node => { node.keepThisNode = true; }") + order = card.get_by_test_id("metadata-order-global") + order.locator('[data-order-direction="down"]').first.click() + draft = order.locator("[data-source-label]").all_text_contents() + page.route( + "**/api/v1/metadata/priorities", + lambda route: route.fulfill( + status=409, + json={"detail": "Source settings changed; reload before saving"}, + ), + ) + card.get_by_role("button", name="Save metadata priority", exact=True).click() + expect(card.get_by_role("alert")).to_contain_text("Settings changed") + expect(order.locator("[data-source-label]")).to_have_text(draft) + assert card.evaluate("node => node.keepThisNode") is True + card.get_by_role("button", name="Load saved priority", exact=True).click() + expect(card.get_by_role("button", name="Save metadata priority", exact=True)).to_be_disabled() + assert card.evaluate("node => node.keepThisNode") is True + + +def test_metadata_health_is_scoped_and_keeps_priority_draft(authed_page, seeded_server): + page = authed_page + SettingsPage(page, seeded_server).goto("metadata") + card = page.get_by_test_id("metadata-source-priority") + expect(card).to_be_visible() + card.get_by_test_id("metadata-order-global").locator( + '[data-order-direction="down"]' + ).first.click() + page.route( + "**/api/v1/metadata/sources/comicvine_local/test", + lambda route: route.fulfill( + json={ + "outcome": { + "source": "comicvine_local", + "status": "rate_limited", + "retry_after_seconds": 30, + }, + "recorded": True, + } + ), + ) + health = card.get_by_test_id("metadata-source-health") + health.get_by_role("button", name="Test ComicVine Local Catalog", exact=True).click() + expect(health.get_by_role("status")).to_contain_text("Rate limited") + expect(health.get_by_role("status")).to_contain_text("30") + expect(card.get_by_role("button", name="Save metadata priority", exact=True)).to_be_enabled() + disabled = health.get_by_role("button", name="Test GCD API v2", exact=True) + expect(disabled).to_be_disabled() + assert "feature_disabled" not in disabled.inner_text() + + +def test_metadata_credential_refresh_preserves_unsaved_priority(authed_page, seeded_server): + page = authed_page + SettingsPage(page, seeded_server).goto("metadata") + card = page.get_by_test_id("metadata-source-priority") + order = card.get_by_test_id("metadata-order-global") + order.locator('[data-order-direction="down"]').first.click() + draft = order.locator("[data-source-label]").all_text_contents() + sources = page.request.get(seeded_server + "/api/v1/metadata/sources").json() + for item in sources: + if item["source"] == "comicvine_api": + item["credential_configured"] = True + item["availability"] = None + page.route("**/api/v1/metadata/sources", lambda route: route.fulfill(json=sources)) + page.evaluate("window.dispatchEvent(new CustomEvent('metadata-credentials-updated'))") + expect(card.get_by_role("button", name="Test ComicVine API", exact=True)).to_be_enabled() + expect(order.locator("[data-source-label]")).to_have_text(draft) + expect(card.get_by_role("button", name="Save metadata priority", exact=True)).to_be_enabled() + + +def test_metadata_save_keeps_controls_and_focus_while_pending(authed_page, seeded_server): + page = authed_page + SettingsPage(page, seeded_server).goto("metadata") + card = page.get_by_test_id("metadata-source-priority") + card.get_by_test_id("metadata-order-global").locator( + '[data-order-direction="down"]' + ).first.click() + page.evaluate("""() => { + const original = window.fetch.bind(window); + window.fetch = (url, options) => url.endsWith('/api/v1/metadata/priorities') + ? new Promise(() => {}) : original(url, options); + }""") + button = card.get_by_role("button", name="Save metadata priority", exact=True) + button.evaluate("node => { node.keepThisButton = true; }") + button.click() + button = card.get_by_role("button", name="Saving...", exact=True) + expect(button).to_be_visible() + expect(button).to_be_disabled() + assert button.evaluate("node => node.keepThisButton") is True + expect(card.get_by_role("button", name="Reset", exact=True)).to_be_disabled() + expect(card.get_by_test_id("metadata-order-global").locator("[data-source-row]")).to_have_count( + 5 + ) + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +@pytest.mark.parametrize("width", [1280, 320]) +def test_metadata_priority_accessibility_and_boundary_focus( + authed_page, seeded_server, theme, width +): + page = authed_page + page.set_viewport_size({"width": width, "height": 900}) + page.emulate_media(reduced_motion="reduce") + SettingsPage(page, seeded_server).goto("metadata") + page.evaluate("theme => document.documentElement.dataset.theme = theme", theme) + card = page.get_by_test_id("metadata-source-priority") + order = card.get_by_test_id("metadata-order-global") + first = order.locator("[data-source-label]").first.inner_text() + order.locator('[data-order-direction="down"]').first.press("Enter") + order.get_by_role("button", name=f"Move {first} up", exact=True).press("Enter") + expect(order.get_by_role("button", name=f"Move {first} down", exact=True)).to_be_focused() + card.get_by_text("Advanced domain priorities", exact=True).click() + card.get_by_label("Use a separate order for Artwork").check() + assert card.evaluate("element => element.scrollWidth <= element.clientWidth + 1") + assert_no_axe_violations( + page, + name=f"metadata-priority-{theme}-{width}", + include=["[data-testid='metadata-source-priority']"], + ) + card.screenshot( + path=f"test-results/metadata-priority-{theme}-{width}.png", animations="disabled" + ) diff --git a/tests/integration/metadata_identity/test_source_priorities.py b/tests/integration/metadata_identity/test_source_priorities.py new file mode 100644 index 00000000..06cbe8b8 --- /dev/null +++ b/tests/integration/metadata_identity/test_source_priorities.py @@ -0,0 +1,134 @@ +"""Whole-order writes are atomic and never replace source credentials/options.""" + +import asyncio +from datetime import UTC, datetime + +import pytest +from pydantic import SecretStr, ValidationError +from sqlalchemy import select + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.metadata_sources import SourcePolicyWrite, SourcePriorityWrite +from pullbox.services.metadata_sources import ( + SourceConfigurationConflictError, + read_source_policies, + save_source_policy, + save_source_priorities, +) + + +def write(policies, **kwargs): + return SourcePriorityWrite( + order=list(reversed([item.source for item in policies])), + revisions={item.source: item.revision for item in policies}, + **kwargs, + ) + + +async def test_order_roundtrip_preserves_secrets_config_and_health(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + await save_source_policy( + session, + MetadataSource.METRON_API, + SourcePolicyWrite( + revision=0, enabled=True, priority=25, credential=SecretStr("priority-test-token") + ), + ) + row = await session.scalar(select(MetadataSourceConfig)) + row.last_status = "ok" + row.last_tested_at = row.last_success_at = datetime.now(UTC) + secret = row.credential_secret + tested = row.last_tested_at + async with factory.begin() as session: + policies = await read_source_policies(session) + payload = write(policies, domain_orders={"core": list(MetadataSource)}) + result = await save_source_priorities(session, payload) + assert [item.source for item in result] == payload.order + assert all(item.revision == payload.revisions[item.source] + 1 for item in result) + assert {item.source: item.domain_priorities["core"] for item in result} == { + source: (index + 1) * 10 for index, source in enumerate(MetadataSource) + } + async with factory() as session: + rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))} + assert rows["metron_api"].credential_secret == secret + assert rows["metron_api"].last_tested_at == tested + assert rows["metron_api"].last_status == "ok" and rows["metron_api"].enabled + assert not rows["gcd_api_v2"].enabled + assert [item.source for item in await read_source_policies(session)] == payload.order + + +async def test_stale_order_rolls_back_every_row_even_when_caller_commits(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + before = await read_source_policies(session) + payload = write(before) + payload.revisions[MetadataSource.METRON_API] = 8 + with pytest.raises(SourceConfigurationConflictError): + await save_source_priorities(session, payload) + assert list(await session.scalars(select(MetadataSourceConfig))) == [] + async with factory() as session: + assert await read_source_policies(session) == before + + +async def test_priority_reset_and_caller_rollback(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + saved = await save_source_priorities( + session, + write( + await read_source_policies(session), domain_orders={"issues": list(MetadataSource)} + ), + ) + assert all("issues" in item.domain_priorities for item in saved) + async with factory() as session: + cleared = await save_source_priorities(session, write(saved)) + assert all(not item.domain_priorities for item in cleared) + await session.rollback() + async with factory.begin() as session: + assert await read_source_policies(session) == saved + await save_source_priorities(session, write(saved)) + async with factory() as session: + assert all(not item.domain_priorities for item in await read_source_policies(session)) + + +async def test_concurrent_order_writes_have_only_one_winner(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory() as session: + payload = write(await read_source_policies(session)) + + async def save(): + try: + async with factory.begin() as session: + return await save_source_priorities(session, payload) + except SourceConfigurationConflictError: + return None + + results = await asyncio.wait_for(asyncio.gather(save(), save()), 15) + assert results.count(None) == 1 + async with factory() as session: + saved = await read_source_policies(session) + assert [item.source for item in saved] == payload.order + assert all(item.revision == 1 for item in saved) + + +@pytest.mark.parametrize( + "mutation", ["duplicate", "missing_revision", "domain_duplicate", "artwork"] +) +def test_order_request_rejects_incomplete_or_unsupported_orders(mutation): + payload = { + "order": list(MetadataSource), + "revisions": dict.fromkeys(MetadataSource, 0), + "domain_orders": {}, + } + if mutation == "duplicate": + payload["order"][-1] = payload["order"][0] + elif mutation == "missing_revision": + payload["revisions"].pop(MetadataSource.GCD_LOCAL) + elif mutation == "domain_duplicate": + payload["domain_orders"] = {"core": [MetadataSource.COMICVINE_API] * 5} + else: + payload["domain_orders"] = {"artwork": list(MetadataSource)} + with pytest.raises(ValidationError): + SourcePriorityWrite.model_validate(payload) diff --git a/tests/ui/test_metadata_sources_settings.py b/tests/ui/test_metadata_sources_settings.py new file mode 100644 index 00000000..5f433206 --- /dev/null +++ b/tests/ui/test_metadata_sources_settings.py @@ -0,0 +1,61 @@ +"""Metadata settings expose masked policy without activating a provider.""" + +import json +import os +import re +import sys + +from pullbox.core.encryption import encrypt_secret +from pullbox.models.metadata_source import MetadataSourceConfig + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +pytest_plugins = ["conftest_security"] + + +async def test_metadata_settings_has_seeded_shared_order_and_preserves_existing_controls( + authenticated_client, + sec_db, +): + async with sec_db.begin() as session: + session.add( + MetadataSourceConfig( + source="metron_api", + enabled=False, + priority=1, + revision=2, + credential_secret=encrypt_secret("do-not-render-provider-token"), + ) + ) + response = await authenticated_client.get("/settings?tab=metadata") + assert response.status_code == 200 + assert 'data-testid="metadata-source-priority"' in response.text + assert "data-order-controls" in response.text + assert 'data-testid="metadata-source-health"' in response.text + assert "Save metadata priority" in response.text + assert "Advanced domain priorities" in response.text + assert "ComicVine access" in response.text and "Local Comic Vine catalog" in response.text + seed = re.search(r"const seed = (.+);", response.text) + assert seed + data = json.loads(seed.group(1)) + assert data[0]["source"] == "metron_api" and data[0]["credential_configured"] + assert all("credential_secret" not in item for item in data) + assert "do-not-render-provider-token" not in response.text + assert "gcd_api_v2" in response.text and "feature_disabled" in response.text + + +async def test_metadata_settings_htmx_keeps_source_controls_and_never_probes_on_load( + authenticated_client, + monkeypatch, +): + from pullbox.services.metadata_discovery import MetadataSourceRegistry + + async def forbidden(*args, **kwargs): + raise AssertionError("Rendering settings must not probe providers") + + monkeypatch.setattr(MetadataSourceRegistry, "check", forbidden) + response = await authenticated_client.get( + "/settings?tab=metadata", headers={"HX-Request": "true"} + ) + assert response.status_code == 200 + assert 'data-testid="metadata-source-priority"' in response.text + assert 'data-testid="metadata-source-health"' in response.text diff --git a/tests/unit/test_config_api_branches.py b/tests/unit/test_config_api_branches.py index e0d20f52..b560f019 100644 --- a/tests/unit/test_config_api_branches.py +++ b/tests/unit/test_config_api_branches.py @@ -419,7 +419,7 @@ async def fake_save_key(_session: AsyncSession, api_key: str) -> None: FakeComicVineProvider, ) monkeypatch.setattr( - "pullbox.core.comicvine_key.save_comicvine_api_key", + "pullbox.services.metadata_sources.save_comicvine_api_key", fake_save_key, ) monkeypatch.setattr( diff --git a/tests/unit/test_standalone_shell.py b/tests/unit/test_standalone_shell.py index 587b8538..a634af5d 100644 --- a/tests/unit/test_standalone_shell.py +++ b/tests/unit/test_standalone_shell.py @@ -9,7 +9,8 @@ @pytest.mark.parametrize( - "script", ["story-arc-preview.js", "story-arc-detail.js", "series-rescan.js"] + "script", + ["story-arc-preview.js", "story-arc-detail.js", "series-rescan.js", "metadata-sources.js"], ) def test_story_arc_controller_change_invalidates_main_shell_cache( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script: str From 13ba6c80ecfdc5abf74c39b3e265bb28b349b694 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 04:15:01 -0700 Subject: [PATCH 21/97] feat(metadata): add bounded Metron source adapter --- src/pullbox/providers/metadata/metron.py | 392 +++++++++ .../metadata/metron_normalization.py | 200 +++++ src/pullbox/providers/metadata/sources.py | 31 +- src/pullbox/schemas/metadata_sources.py | 59 +- src/pullbox/services/metadata_discovery.py | 13 +- tests/api/test_metadata_sources_api.py | 69 ++ tests/unit/test_metron_source.py | 783 ++++++++++++++++++ 7 files changed, 1539 insertions(+), 8 deletions(-) create mode 100644 src/pullbox/providers/metadata/metron.py create mode 100644 src/pullbox/providers/metadata/metron_normalization.py create mode 100644 tests/unit/test_metron_source.py diff --git a/src/pullbox/providers/metadata/metron.py b/src/pullbox/providers/metadata/metron.py new file mode 100644 index 00000000..b005250f --- /dev/null +++ b/src/pullbox/providers/metadata/metron.py @@ -0,0 +1,392 @@ +"""Bounded, read-only Metron HTTP adapter; no implicit identity attachment.""" + +from __future__ import annotations + +import asyncio +import json +import math +import time +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from typing import TYPE_CHECKING +from urllib.parse import parse_qsl, urlsplit + +import httpx + +from pullbox import __version__ +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.provider_cooldown import ProviderCooldown, provider_cooldown, retry_after_seconds +from pullbox.providers.metadata import metron_normalization as normalize +from pullbox.schemas.metadata_sources import ( + MetadataFetch, + MetadataPage, + ProviderIssueRead, + ProviderSeriesRead, + ProviderStoryArcRead, + SourceStatus, +) +from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage + +if TYPE_CHECKING: + from collections.abc import Callable + + from pydantic import SecretStr + + from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery + +_MAX_BYTES = 2 * 1024 * 1024 +_PAGE_SIZE = 100 +_REQUEST_TIMEOUT = 8.0 + + +@dataclass(frozen=True) +class _Response: + status: SourceStatus + payload: object = None + validator: str | None = None + + +def _json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result = dict(pairs) + if len(result) != len(pairs): + raise ValueError("Duplicate JSON field") + return result + + +def _json_constant(value: str) -> None: + raise ValueError("Nonstandard JSON constant") + + +def _validator(value: str | None) -> str | None: + if value is not None: + if len(value) > 128 or "\r" in value or "\n" in value: + raise ValueError("Invalid refresh validator") + when = parsedate_to_datetime(value) + if when.tzinfo is None: + raise ValueError("Refresh validator must include a timezone") + return value + + +def _page_number(page: int) -> None: + if type(page) is not int or not 1 <= page <= 10000: + raise ValueError("Metron page must be between one and ten thousand") + + +def _metadata_page[T](rows: list[T], total: int, next_page: int | None) -> MetadataPage[T]: + truncated = next_page is not None and next_page > 10000 + return MetadataPage( + results=rows, total=total, next_page=None if truncated else next_page, truncated=truncated + ) + + +def _envelope( + payload: object, path: str, params: dict[str, str] +) -> tuple[list[object], int, int | None]: + row = normalize.object_row(payload) + total, rows = row.get("count"), row.get("results") + if type(total) is not int or not 0 <= total <= 1_000_000_000 or not isinstance(rows, list): + raise ValueError("Invalid pagination envelope") + page = int(params["page"]) + remaining = max(0, total - (page - 1) * _PAGE_SIZE) + if len(rows) != min(_PAGE_SIZE, remaining): + raise ValueError("Incomplete source page") + next_page = page + 1 if remaining > _PAGE_SIZE else None + link = row.get("next") + if next_page is None: + if link is not None: + raise ValueError("Unexpected continuation") + else: + if not isinstance(link, str) or len(link) > 4096: + raise ValueError("Missing continuation") + url = urlsplit(link) + query = parse_qsl(url.query, keep_blank_values=True, max_num_fields=10) + if ( + url.scheme != "https" + or url.hostname != "metron.cloud" + or url.port not in {None, 443} + or url.username is not None + or url.password is not None + or url.fragment + or url.path != f"/api/{path}" + or len(query) != len(dict(query)) + or dict(query) != {**params, "page": str(next_page)} + or any(char.isspace() or ord(char) < 32 for char in link) + ): + raise ValueError("Invalid continuation") + return rows, total, next_page + + +class MetronSource: + def __init__( + self, + token: SecretStr, + *, + transport: httpx.AsyncBaseTransport | None = None, + cooldown: ProviderCooldown | None = None, + minimum_interval: float = 3.0, + ) -> None: + credential = token.get_secret_value() + if not credential: + raise MetadataSourceError(SourceStatus.UNCONFIGURED) + if ( + len(credential) > 4096 + or not credential.isascii() + or credential.startswith("enc:") + or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in credential) + ): + raise MetadataSourceError(SourceStatus.INVALID_CONFIG) + if not math.isfinite(minimum_interval) or not 0 <= minimum_interval <= 3: + raise ValueError("Invalid request spacing") + self.cooldown = cooldown or provider_cooldown("metron", credential) + self.minimum_interval = minimum_interval + self.client = httpx.AsyncClient( + base_url="https://metron.cloud/api/", + headers={ + "Authorization": f"Bearer {credential}", + "User-Agent": f"Pullbox/{__version__}", + "Accept": "application/json", + }, + timeout=httpx.Timeout(_REQUEST_TIMEOUT, connect=4), + limits=httpx.Limits(max_connections=2, max_keepalive_connections=1), + follow_redirects=False, + transport=transport, + ) + + def _rate_headers(self, headers: httpx.Headers) -> None: + for scope in ("Burst", "Sustained"): + try: + if headers.get(f"X-RateLimit-{scope}-Remaining") == "0": + reset = float(headers[f"X-RateLimit-{scope}-Reset"]) - time.time() + if math.isfinite(reset) and reset > 0: + self.cooldown.defer(min(reset, 7 * 86400)) + except (ValueError, KeyError): + pass + + async def _exchange( + self, path: str, params: dict[str, str], validator: str | None + ) -> _Response: + headers = {"If-Modified-Since": validator} if validator else {} + async with self.client.stream("GET", path, params=params, headers=headers) as response: + self._rate_headers(response.headers) + status = response.status_code + if status in {401, 403}: + raise MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED) + if status == 429: + self.cooldown.defer( + retry_after_seconds(response.headers.get("Retry-After"), default=60) + ) + raise MetadataSourceError( + SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds + ) + if status in {500, 502, 503, 504}: + if "Retry-After" in response.headers: + self.cooldown.defer( + retry_after_seconds(response.headers["Retry-After"], default=1) + ) + raise MetadataSourceError( + SourceStatus.UNAVAILABLE, self.cooldown.remaining_seconds or None + ) + if status == 404: + return _Response(SourceStatus.NOT_FOUND) + if status == 304 and validator: + return _Response(SourceStatus.NOT_MODIFIED, validator=validator) + if ( + status != 200 + or response.headers.get("content-type", "").split(";")[0] != "application/json" + ): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + body = bytearray() + async for chunk in response.aiter_bytes(): + if len(body) + len(chunk) > _MAX_BYTES: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + body.extend(chunk) + try: + payload = json.loads( + body, object_pairs_hook=_json_object, parse_constant=_json_constant + ) + modified = _validator(response.headers.get("Last-Modified")) + except (ValueError, TypeError, OverflowError, RecursionError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + return _Response(SourceStatus.OK, payload, modified) + + async def _get( + self, path: str, params: dict[str, str] | None = None, validator: str | None = None + ) -> _Response: + _validator(validator) + try: + async with asyncio.timeout(_REQUEST_TIMEOUT): + for attempt in range(2): + try: + async with self.cooldown.request_lock: + if self.cooldown.remaining_seconds: + raise MetadataSourceError( + SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds + ) + delay = self.minimum_interval - ( + time.monotonic() - self.cooldown.last_request_time + ) + if delay > 0: + await asyncio.sleep(delay) + self.cooldown.last_request_time = time.monotonic() + return await self._exchange(path, params or {}, validator) + except MetadataSourceError as exc: + if ( + exc.status is not SourceStatus.UNAVAILABLE + or exc.retry_after_seconds + or attempt + ): + raise + except httpx.TimeoutException: + raise MetadataSourceError(SourceStatus.TIMEOUT) from None + except (httpx.NetworkError, httpx.RemoteProtocolError): + if attempt: + raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None + await asyncio.sleep(0.1) + except TimeoutError: + raise MetadataSourceError(SourceStatus.TIMEOUT) from None + except httpx.HTTPError: + raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None + raise MetadataSourceError(SourceStatus.UNAVAILABLE) + + async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: + limit = query.limit_per_source + if type(offset) is not int or not 0 <= offset <= 10000 or offset % limit: + raise ValueError("Invalid source offset") + params = {"name": query.query, "page": str(offset // _PAGE_SIZE + 1)} + if query.year is not None: + params["year_began"] = str(query.year) + results = [] + rejected = 0 + consumed = 0 + seen = set() + total = None + try: + for _ in range(2): + response = await self._get("series/", params) + if response.status is not SourceStatus.OK: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + rows, current_total, next_page = _envelope(response.payload, "series/", params) + if total is not None and total != current_total: + raise ValueError("Source changed between pages") + total = current_total + start = offset % _PAGE_SIZE if not consumed else 0 + selected = rows[start : start + limit - consumed] + consumed += len(selected) + for row in selected: + try: + result = normalize.series(row) + if result.external_id in seen: + raise ValueError("Repeated source identity") + seen.add(result.external_id) + results.append(result) + except (ValueError, TypeError): + rejected += 1 + if consumed >= limit or next_page is None: + break + params["page"] = str(next_page) + more = total is not None and offset + consumed < total + next_offset = offset + limit if more and offset + limit <= 10000 else None + return SourcePage(results, total, next_offset, rejected, more and next_offset is None) + except (ValueError, TypeError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + + async def check(self) -> None: + from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery + + page = await self.search(SeriesDiscoveryQuery(query="test", limit_per_source=1), 0) + if page.rejected_results: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + + async def close(self) -> None: + await self.client.aclose() + + async def _detail[T: (ProviderSeriesRead, ProviderIssueRead, ProviderStoryArcRead)]( + self, + path: str, + identifier: str, + normalize_row: Callable[[object], T], + validator: str | None, + ) -> MetadataFetch[T]: + identifier = normalize.external_id(identifier) + response = await self._get(f"{path}/{identifier}/", validator=validator) + if response.status is not SourceStatus.OK: + return MetadataFetch(status=response.status, validator=response.validator) + try: + row = normalize_row(response.payload) + if row.external_id != identifier: + raise ValueError("Different detail identity") + return MetadataFetch(status=SourceStatus.OK, data=row, validator=response.validator) + except (ValueError, TypeError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + + async def series( + self, external_id: str, *, validator: str | None = None + ) -> MetadataFetch[ProviderSeriesRead]: + return await self._detail( + "series", external_id, lambda row: normalize.series(row, detail=True), validator + ) + + async def issue( + self, external_id: str, *, validator: str | None = None + ) -> MetadataFetch[ProviderIssueRead]: + return await self._detail("issue", external_id, normalize.issue, validator) + + async def story_arc( + self, external_id: str, *, validator: str | None = None + ) -> MetadataFetch[ProviderStoryArcRead]: + return await self._detail("arc", external_id, normalize.story_arc, validator) + + async def issues( + self, + external_id: str, + *, + kind: MetadataEntityKind = MetadataEntityKind.SERIES, + page: int = 1, + validator: str | None = None, + ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: + if not isinstance(kind, MetadataEntityKind) or kind not in { + MetadataEntityKind.SERIES, + MetadataEntityKind.STORY_ARC, + }: + raise ValueError("Unsupported issue-list parent") + identifier = normalize.external_id(external_id) + _page_number(page) + path = ( + f"{'series' if kind is MetadataEntityKind.SERIES else 'arc'}/{identifier}/issue_list/" + ) + params = {"page": str(page)} + response = await self._get(path, params, validator) + if response.status is not SourceStatus.OK: + return MetadataFetch(status=response.status, validator=response.validator) + try: + rows, total, next_page = _envelope(response.payload, path, params) + issues = [normalize.issue(row) for row in rows] + if len({issue.external_id for issue in issues}) != len(issues) or ( + kind is MetadataEntityKind.SERIES + and any(issue.series_external_id != identifier for issue in issues) + ): + raise ValueError("Inconsistent issue membership") + return MetadataFetch( + status=SourceStatus.OK, + validator=response.validator, + data=_metadata_page(issues, total, next_page), + ) + except (ValueError, TypeError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + + async def story_arcs(self, query: str, *, page: int = 1) -> MetadataPage[ProviderStoryArcRead]: + if not isinstance(query, str) or not query.strip() or len(query) > 200: + raise ValueError("Expected a bounded arc query") + _page_number(page) + params = {"name": query.strip(), "page": str(page)} + response = await self._get("arc/", params) + if response.status is not SourceStatus.OK: + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) + try: + rows, total, next_page = _envelope(response.payload, "arc/", params) + results = [normalize.story_arc(row) for row in rows] + if len({row.external_id for row in results}) != len(results): + raise ValueError("Repeated source identity") + return _metadata_page(results, total, next_page) + except (ValueError, TypeError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None diff --git a/src/pullbox/providers/metadata/metron_normalization.py b/src/pullbox/providers/metadata/metron_normalization.py new file mode 100644 index 00000000..6b7f0f76 --- /dev/null +++ b/src/pullbox/providers/metadata/metron_normalization.py @@ -0,0 +1,200 @@ +"""Validate Metron serializer shapes before exposing shared metadata DTOs.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from typing import cast +from urllib.parse import unquote, urlsplit + +from pullbox.core.html_sanitizer import sanitize_rich_html +from pullbox.core.issue_numbers import normalize_issue_number_text +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, + MetadataSource, +) +from pullbox.schemas.metadata_sources import ( + ProviderIssueRead, + ProviderSeriesRead, + ProviderStoryArcRead, +) + + +def object_row(value: object) -> dict[str, object]: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise ValueError("Expected a metadata object") + return cast("dict[str, object]", value) + + +def external_id(value: object) -> str: + if type(value) not in {int, str}: + raise ValueError("Expected a positive provider identity") + return ExternalIdentityRef( + IdentityNamespace.METRON, MetadataEntityKind.SERIES, str(value) + ).external_id + + +def _text(value: object, *, required: bool = False, limit: int = 500) -> str | None: + if value is None and not required: + return None + if not isinstance(value, str) or len(value) > limit or (required and not value.strip()): + raise ValueError("Expected bounded metadata text") + return value.strip() or None + + +def _integer(value: object) -> int | None: + if value is None: + return None + if type(value) is not int or not 0 <= value <= 1_000_000_000: + raise ValueError("Expected a nonnegative metadata integer") + return value + + +def _date(value: object) -> date | None: + if value is None: + return None + if not isinstance(value, str) or len(value) != 10: + raise ValueError("Expected an ISO calendar date") + return date.fromisoformat(value) + + +def _updated(value: object) -> datetime | None: + if value is None: + return None + if not isinstance(value, str) or len(value) > 48: + raise ValueError("Expected a source timestamp") + result = datetime.fromisoformat(value) + if result.tzinfo is None: + raise ValueError("Source timestamp must include its timezone") + return result.astimezone(UTC) + + +def _url(value: object, path: str, *, image: bool = False) -> str | None: + if not isinstance(value, str) or len(value) > 4096: + return None + try: + url = urlsplit(value) + decoded = unquote(url.path) + if ( + url.scheme == "https" + and url.hostname + in ({"metron.cloud", "static.metron.cloud"} if image else {"metron.cloud"}) + and url.port in {None, 443} + and url.username is None + and url.password is None + and not url.query + and not url.fragment + and decoded.startswith(path) + and ".." not in decoded.split("/") + and "\\" not in decoded + and not any(char.isspace() or ord(char) < 32 for char in value + decoded) + ): + return value + except ValueError: + pass + return None + + +def _crosswalks(row: dict[str, object], kind: MetadataEntityKind) -> list[ExternalIdentityRef]: + identities = [] + for field, namespace in ( + ("cv_id", IdentityNamespace.COMICVINE), + ("gcd_id", IdentityNamespace.GCD), + ): + if row.get(field) is not None: + identities.append(ExternalIdentityRef(namespace, kind, external_id(row[field]))) + return identities + + +def _description(row: dict[str, object]) -> str | None: + value = _text(row.get("desc"), limit=20000) + return sanitize_rich_html(value) if value else None + + +def _named(value: object) -> str | None: + return _text(object_row(value).get("name"), required=True) if value is not None else None + + +def series(value: object, *, detail: bool = False) -> ProviderSeriesRead: + row = object_row(value) + title = _text(row.get("name" if detail else "series"), required=True) + assert title is not None + year = _integer(row.get("year_began")) + if not detail and year is not None: + kind = object_row(row["series_type"]).get("id") if row.get("series_type") else None + # These suffixes come from Series.__str__, not from the stored name. + suffix = { + 12: f" ({year}) Digital", + 10: f" TPB ({year})", + 8: f" HC ({year})", + 9: f" GN ({year})", + }.get(_integer(kind) or 0, f" ({year})") + title = title.removesuffix(suffix) + if not title.strip(): + raise ValueError("Expected a series title") + volume = _integer(row.get("volume")) + status = _text(row.get("status")) + return ProviderSeriesRead( + source=MetadataSource.METRON_API, + identity_namespace=IdentityNamespace.METRON, + external_id=external_id(row.get("id")), + title=title, + year_start=year, + year_end=_integer(row.get("year_end")), + publisher=_named(row.get("publisher")), + issue_count=_integer(row.get("issue_count")), + description=_description(row), + resource_url=_url(row.get("resource_url"), "/series/"), + cross_identities=_crosswalks(row, MetadataEntityKind.SERIES), + source_updated_at=_updated(row.get("modified")), + sort_title=_text(row.get("sort_name")), + volume=str(volume) if volume is not None else None, + series_type=_named(row.get("series_type")), + status=status.lower() if status else None, + language=_text(row.get("language")), + ) + + +def issue(value: object) -> ProviderIssueRead: + row = object_row(value) + number = _text(row.get("number"), required=True, limit=100) + assert number is not None + try: + key = normalize_issue_number_text(number) + except ValueError: + key = None + return ProviderIssueRead( + source=MetadataSource.METRON_API, + identity_namespace=IdentityNamespace.METRON, + external_id=external_id(row.get("id")), + series_external_id=external_id(object_row(row.get("series")).get("id")), + issue_number_text=number, + issue_number_key=key, + title=_text(row.get("title")), + description=_description(row), + cover_date=_date(row.get("cover_date")), + store_date=_date(row.get("store_date")), + page_count=_integer(row.get("page")), + resource_url=_url(row.get("resource_url"), "/issue/"), + image_url=_url(row.get("image"), "/media/", image=True), + cross_identities=_crosswalks(row, MetadataEntityKind.ISSUE), + source_updated_at=_updated(row.get("modified")), + ) + + +def story_arc(value: object) -> ProviderStoryArcRead: + row = object_row(value) + title = _text(row.get("name"), required=True) + assert title is not None + return ProviderStoryArcRead( + source=MetadataSource.METRON_API, + identity_namespace=IdentityNamespace.METRON, + external_id=external_id(row.get("id")), + title=title, + description=_description(row), + resource_url=_url(row.get("resource_url"), "/arc/"), + image_url=_url(row.get("image"), "/media/", image=True), + cross_identities=_crosswalks(row, MetadataEntityKind.STORY_ARC), + source_updated_at=_updated(row.get("modified")), + ) diff --git a/src/pullbox/providers/metadata/sources.py b/src/pullbox/providers/metadata/sources.py index 212b2c0e..a6467d33 100644 --- a/src/pullbox/providers/metadata/sources.py +++ b/src/pullbox/providers/metadata/sources.py @@ -1,4 +1,4 @@ -"""Executable ComicVine adapters; keep legacy import/cache consumers unchanged.""" +"""Executable source registrations; keep legacy import/cache consumers unchanged.""" from __future__ import annotations @@ -9,6 +9,7 @@ from pullbox.config import get_settings from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider +from pullbox.providers.metadata.metron import MetronSource from pullbox.schemas.metadata_sources import ( ProviderSeriesRead, SeriesDiscoveryQuery, @@ -235,3 +236,31 @@ def comicvine_sources() -> dict[MetadataSource, SourceRegistration]: lambda runtime: ComicVineLocalSource(get_catalog_reader()), ), } + + +def _metron(runtime: SourceRuntime) -> MetronSource: + if runtime.credential is None: + raise MetadataSourceError(SourceStatus.UNCONFIGURED) + return MetronSource(runtime.credential) + + +def metadata_sources() -> dict[MetadataSource, SourceRegistration]: + return { + **comicvine_sources(), + MetadataSource.METRON_API: SourceRegistration( + frozenset( + { + SourceCapability.SERIES_SEARCH, + SourceCapability.SERIES_DETAILS, + SourceCapability.ISSUE_LIST, + SourceCapability.ISSUE_DETAILS, + SourceCapability.STORY_ARC_SEARCH, + SourceCapability.STORY_ARC_DETAILS, + SourceCapability.CROSS_IDENTITIES, + SourceCapability.CONDITIONAL_REFRESH, + SourceCapability.COVER_REFERENCE, + } + ), + _metron, + ), + } diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index baf87c23..c2bcd76e 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -1,12 +1,12 @@ """Versioned source policy and provider-aware discovery responses.""" import enum -from datetime import datetime +from datetime import date, datetime from typing import Annotated, Literal, Self from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator -from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource +from pullbox.core.metadata_identity import ExternalIdentityRef, IdentityNamespace, MetadataSource class MetadataDomain(enum.StrEnum): @@ -44,6 +44,8 @@ class SourceStatus(enum.StrEnum): INCOMPATIBLE_RESPONSE = "incompatible_response" UNSUPPORTED = "unsupported" NOT_QUERIED = "not_queried" + NOT_FOUND = "not_found" + NOT_MODIFIED = "not_modified" class SourceSettings(BaseModel): @@ -136,6 +138,59 @@ class ProviderSeriesRead(BaseModel): resource_url: str | None = None image_url: str | None = None also_from: list[MetadataSource] = Field(default_factory=list) + cross_identities: list[ExternalIdentityRef] = Field(default_factory=list) + source_updated_at: datetime | None = None + sort_title: str | None = None + year_end: int | None = None + volume: str | None = None + series_type: str | None = None + status: str | None = None + language: str | None = None + + +class ProviderIssueRead(BaseModel): + source: MetadataSource + identity_namespace: IdentityNamespace + external_id: str + series_external_id: str + issue_number_text: str + issue_number_key: str | None = None + title: str | None = None + description: str | None = None + cover_date: date | None = None + store_date: date | None = None + page_count: int | None = None + resource_url: str | None = None + image_url: str | None = None + cross_identities: list[ExternalIdentityRef] = Field(default_factory=list) + source_updated_at: datetime | None = None + + +class ProviderStoryArcRead(BaseModel): + source: MetadataSource + identity_namespace: IdentityNamespace + external_id: str + title: str + description: str | None = None + resource_url: str | None = None + image_url: str | None = None + cross_identities: list[ExternalIdentityRef] = Field(default_factory=list) + source_updated_at: datetime | None = None + + +class MetadataPage[T](BaseModel): + results: list[T] + total: int + next_page: int | None = None + truncated: bool = False + # Provider publication order is not a reviewed story-arc reading order. + order_is_reading_order: bool = False + + +class MetadataFetch[T](BaseModel): + status: SourceStatus + data: T | None = None + validator: str | None = None class SourceOutcome(BaseModel): diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index f658aa14..d3729dd2 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -33,9 +33,9 @@ def describe_source_policies( policies: Sequence[SourcePolicyRead], *, gcd_api_enabled: bool ) -> list[SourceDescriptor]: """Expose capability/configuration snapshots without constructing clients.""" - from pullbox.providers.metadata.sources import comicvine_sources + from pullbox.providers.metadata.sources import metadata_sources - registrations = comicvine_sources() + registrations = metadata_sources() result = [] for policy in policies: registration = registrations.get(policy.source) @@ -46,7 +46,10 @@ def describe_source_policies( availability = availability or SourceStatus.DISABLED elif registration is None: availability = SourceStatus.NOT_IMPLEMENTED - elif policy.source is MetadataSource.COMICVINE_API and not policy.credential_configured: + elif ( + policy.source in {MetadataSource.COMICVINE_API, MetadataSource.METRON_API} + and not policy.credential_configured + ): availability = SourceStatus.UNCONFIGURED result.append( SourceDescriptor( @@ -98,9 +101,9 @@ def __init__( concurrency: int = 3, ) -> None: if factories is None: - from pullbox.providers.metadata.sources import comicvine_sources + from pullbox.providers.metadata.sources import metadata_sources - factories = comicvine_sources() + factories = metadata_sources() self.runtime = {item.policy.source: item for item in runtime} self.factories = factories self.gcd_api_enabled = gcd_api_enabled diff --git a/tests/api/test_metadata_sources_api.py b/tests/api/test_metadata_sources_api.py index 0bece03e..237e44f3 100644 --- a/tests/api/test_metadata_sources_api.py +++ b/tests/api/test_metadata_sources_api.py @@ -213,3 +213,72 @@ async def test_existing_key_save_invalidates_source_revision_and_old_health( assert not await record_source_health( session, MetadataSource.COMICVINE_API, revision, outcome, now ) + + +async def test_metron_configuration_health_and_discovery_use_real_adapter( + authenticated_client, monkeypatch, sec_db, tmp_path +): + import httpx + + from pullbox.core.provider_cooldown import ProviderCooldown + from pullbox.providers.metadata import sources + from tests.unit.test_metron_source import envelope, series_row + + token = "synthetic-api-metron-token" + requests = [] + fail = False + + def handle(request): + requests.append(request) + assert request.headers["authorization"] == f"Bearer {token}" + if fail: + return httpx.Response(401, text=token) + return httpx.Response(200, json=envelope([series_row()])) + + adapter = sources.MetronSource + monkeypatch.setattr( + sources, + "MetronSource", + lambda credential: adapter( + credential, + transport=httpx.MockTransport(handle), + cooldown=ProviderCooldown(), + minimum_interval=0, + ), + ) + reader = installed_reader(tmp_path) + monkeypatch.setattr(sources, "get_catalog_reader", lambda: reader) + url = "/api/v1/metadata/sources/metron_api" + saved = await authenticated_client.put( + url, json=policy(credential=token), headers=csrf(authenticated_client) + ) + assert saved.status_code == 200 and saved.json()["credential_configured"] + health = await authenticated_client.post(url + "/test", headers=csrf(authenticated_client)) + assert health.status_code == 200 and health.json()["outcome"]["status"] == "ok" + async with sec_db() as session: + row = await session.scalar( + select(MetadataSourceConfig).where(MetadataSourceConfig.source == "metron_api") + ) + assert row.last_status == "ok" and row.last_success_at + result = await authenticated_client.post( + "/api/v1/metadata/search", + json={"query": "Fixture", "sources": ["metron_api"]}, + headers=csrf(authenticated_client), + ) + assert result.status_code == 200 + assert result.json()["results"][0]["external_id"] == "1" + assert result.json()["results"][0]["cross_identities"][0]["external_id"] == "9001" + assert len(requests) == 2 + fail = True + partial = await authenticated_client.post( + "/api/v1/metadata/search", + json={"query": "Dark Knight", "sources": ["comicvine_local", "metron_api"]}, + headers=csrf(authenticated_client), + ) + assert partial.status_code == 200 + assert partial.json()["results"][0]["source"] == "comicvine_local" + assert {row["source"]: row["status"] for row in partial.json()["sources"]} == { + "comicvine_local": "ok", + "metron_api": "authentication_failed", + } + assert token not in saved.text + health.text + result.text + partial.text diff --git a/tests/unit/test_metron_source.py b/tests/unit/test_metron_source.py new file mode 100644 index 00000000..82028756 --- /dev/null +++ b/tests/unit/test_metron_source.py @@ -0,0 +1,783 @@ +"""Synthetic fixtures follow Metron serializers at 7cd77f3, observed 2026-09-28.""" + +import asyncio +import time + +import httpx +import pytest +from pydantic import SecretStr + +from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource +from pullbox.core.provider_cooldown import ProviderCooldown +from pullbox.providers.metadata.metron import MetronSource +from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery, SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry +from pullbox.services.metadata_sources import SourceRuntime, default_policy + +TOKEN = "synthetic-metron-token-not-a-credential" +MODIFIED = "Wed, 16 Sep 2026 12:00:00 GMT" + + +def series_row(identifier=1): + return { + "id": identifier, + "series": f"Fixture {identifier} (2024)", + "year_began": 2024, + "year_end": None, + "volume": 1, + "issue_count": 3, + "publisher": {"id": 5, "name": "Fixture Press"}, + "series_type": {"id": 1, "name": "Single Issue"}, + "cv_id": 9000 + identifier, + "gcd_id": 8000 + identifier, + "modified": "2026-09-16T12:00:00Z", + } + + +def issue_row(identifier=1, number="50-x"): + return { + "id": identifier, + "series": {"id": 8, "name": "Fixture", "volume": 1, "year_began": 2024}, + "number": number, + "title": "A title", + "cover_date": "2024-01-01", + "store_date": None, + "image": "https://static.metron.cloud/media/issue/fixture.jpg", + "modified": "2026-09-16T12:00:00Z", + } + + +def envelope(rows, *, count=None, next_url=None): + return { + "count": len(rows) if count is None else count, + "results": rows, + "next": next_url, + "previous": None, + } + + +def source(handler, *, cooldown=None, minimum_interval=0): + return MetronSource( + SecretStr(TOKEN), + transport=httpx.MockTransport(handler), + cooldown=cooldown or ProviderCooldown(), + minimum_interval=minimum_interval, + ) + + +async def test_search_maps_real_list_shape_and_keeps_exact_crosswalks(): + requests = [] + + def handle(request): + requests.append(request) + return httpx.Response(200, json=envelope([series_row()])) + + client = source(handle) + try: + result = await client.search(SeriesDiscoveryQuery(query="Fixture", year=2024), 0) + assert len(result.results) == 1 + row = result.results[0] + assert row.title == "Fixture 1" and row.year_start == 2024 + assert row.publisher == "Fixture Press" and row.external_id == "1" + assert row.identity_namespace == "metron" and row.source == "metron_api" + assert {(i.namespace.value, i.external_id) for i in row.cross_identities} == { + ("comicvine", "9001"), + ("gcd", "8001"), + } + assert row.resource_url is None and row.image_url is None + assert requests[0].headers["authorization"] == f"Bearer {TOKEN}" + assert requests[0].url.host == "metron.cloud" and requests[0].url.scheme == "https" + assert dict(requests[0].url.params) == { + "name": "Fixture", + "year_began": "2024", + "page": "1", + } + assert "Pullbox/" in requests[0].headers["user-agent"] + finally: + await client.close() + + +async def test_search_slices_fixed_hundred_row_pages_without_losing_rows(): + seen = [] + + def handle(request): + page = int(request.url.params["page"]) + seen.append(page) + rows = [series_row(i) for i in range(1, 131)][(page - 1) * 100 : page * 100] + return httpx.Response( + 200, + json=envelope( + rows, + count=130, + next_url="https://metron.cloud/api/series/?name=Fixture&page=2" + if page == 1 + else None, + ), + ) + + client = source(handle) + try: + result = await client.search(SeriesDiscoveryQuery(query="Fixture", limit_per_source=30), 90) + assert [int(row.external_id) for row in result.results] == list(range(91, 121)) + assert result.total == 130 and result.next_offset == 120 and seen == [1, 2] + finally: + await client.close() + + +@pytest.mark.parametrize("bad", [True, 0, -1, "4050-12", 1.5]) +async def test_malformed_search_identity_is_rejected_not_silently_accepted(bad): + client = source( + lambda request: httpx.Response( + 200, json=envelope([{**series_row(), "id": bad}, series_row(9)]) + ) + ) + try: + result = await client.search(SeriesDiscoveryQuery(query="Fixture"), 0) + assert result.rejected_results == 1 + assert [row.external_id for row in result.results] == ["9"] + finally: + await client.close() + + +async def test_detail_normalization_and_conditional_status(): + seen = [] + + def handle(request): + seen.append(request) + if "if-modified-since" in request.headers: + return httpx.Response(304) + return httpx.Response( + 200, + headers={"Last-Modified": MODIFIED}, + json={ + **series_row(8), + "name": "Fixture (Special)", + "sort_name": "Fixture Special", + "desc": "

Good

", + "language": "en", + "status": "Continuing", + "resource_url": "https://metron.cloud/series/fixture-2024/", + }, + ) + + client = source(handle) + try: + result = await client.series("0008") + assert result.status == SourceStatus.OK and result.data.title == "Fixture (Special)" + assert result.data.description == "

Good

" and result.validator == MODIFIED + unchanged = await client.series("8", validator=result.validator) + assert unchanged.status == SourceStatus.NOT_MODIFIED and unchanged.data is None + assert seen[-1].headers["if-modified-since"] == MODIFIED + assert seen[-1].url.path == "/api/series/8/" + finally: + await client.close() + + +@pytest.mark.parametrize( + "number,key", [("50-x", "50-X"), ("13a", "13A"), ("-1", "-1"), ("0.5", "0.5"), ("Annual", None)] +) +async def test_issue_numbers_page_field_and_issue_crosswalks(number, key): + client = source( + lambda request: httpx.Response( + 200, json={**issue_row(7, number), "page": 48, "cv_id": 901, "gcd_id": 902} + ) + ) + try: + result = await client.issue("7") + assert result.status == SourceStatus.OK + row = result.data + assert row.issue_number_text == number and row.issue_number_key == key + assert row.series_external_id == "8" and row.page_count == 48 + assert all( + identity.entity_kind == MetadataEntityKind.ISSUE for identity in row.cross_identities + ) + assert row.cover_date.isoformat() == "2024-01-01" + finally: + await client.close() + + +async def test_story_arc_search_details_and_members_are_not_claimed_as_reading_order(): + def handle(request): + if request.url.path.endswith("issue_list/"): + return httpx.Response(200, json=envelope([issue_row()])) + row = { + "id": 4, + "name": "Synthetic arc", + "cv_id": 92, + "gcd_id": None, + "modified": "2026-09-16T12:00:00Z", + } + return httpx.Response(200, json=envelope([row]) if request.url.path == "/api/arc/" else row) + + client = source(handle) + try: + found = await client.story_arcs("Synthetic") + assert found.results[0].external_id == "4" + detail = await client.story_arc("4") + assert detail.data.cross_identities[0].entity_kind == MetadataEntityKind.STORY_ARC + members = await client.issues("4", kind=MetadataEntityKind.STORY_ARC) + assert members.data.results[0].series_external_id == "8" + assert members.data.order_is_reading_order is False + finally: + await client.close() + + +async def test_series_issue_list_rejects_wrong_parent(): + client = source(lambda request: httpx.Response(200, json=envelope([issue_row()]))) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issues("9") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +@pytest.mark.parametrize( + "status,expected", + [ + (401, SourceStatus.AUTHENTICATION_FAILED), + (403, SourceStatus.AUTHENTICATION_FAILED), + (404, SourceStatus.NOT_FOUND), + (500, SourceStatus.UNAVAILABLE), + (302, SourceStatus.INCOMPATIBLE_RESPONSE), + ], +) +async def test_statuses_are_typed_and_raw_errors_do_not_leak(status, expected, caplog): + seen = [] + + def handle(request): + seen.append(request) + return httpx.Response( + status, text=TOKEN, headers={"Location": "https://elsewhere.invalid/steal"} + ) + + client = source(handle) + try: + if status == 404: + assert (await client.issue("7")).status == expected + else: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == expected and TOKEN not in str(error.value) + assert len(seen) == (2 if status == 500 else 1) + assert TOKEN not in caplog.text + assert all(request.url.host == "metron.cloud" for request in seen) + finally: + await client.close() + + +@pytest.mark.parametrize( + "bad", + [ + "https://evil.invalid/api/series/?page=2", + "https://metron.cloud/api/issue/?page=2", + "https://metron.cloud/api/series/?page=1", + "https://metron.cloud/api/series/?page=2&name=Other", + ], +) +async def test_untrusted_pagination_cannot_change_origin_endpoint_or_query(bad): + client = source( + lambda request: httpx.Response( + 200, json=envelope([series_row(i) for i in range(1, 101)], count=101, next_url=bad) + ) + ) + try: + with pytest.raises(MetadataSourceError) as error: + await client.search(SeriesDiscoveryQuery(query="Fixture"), 0) + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"count": True, "results": [], "next": None}, + {"count": 2, "results": [], "next": None}, + {"count": 1, "results": "wrong", "next": None}, + ], +) +async def test_invalid_success_envelope_is_not_an_empty_success(payload): + client = source(lambda request: httpx.Response(200, json=payload)) + try: + with pytest.raises(MetadataSourceError) as error: + await client.search(SeriesDiscoveryQuery(query="Fixture"), 0) + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +async def test_shared_cooldown_honors_retry_after_and_sustained_headers(): + cooldown = ProviderCooldown() + count = 0 + + def handle(request): + nonlocal count + count += 1 + return httpx.Response( + 429, + headers={ + "Retry-After": "45", + "X-RateLimit-Sustained-Remaining": "0", + "X-RateLimit-Sustained-Reset": str(time.time() + 120), + }, + text=TOKEN, + ) + + for _ in range(2): + client = source(handle, cooldown=cooldown) + try: + with pytest.raises(MetadataSourceError) as error: + await client.check() + assert error.value.status == SourceStatus.RATE_LIMITED + assert 115 <= error.value.retry_after_seconds <= 121 + finally: + await client.close() + assert count == 1 + + +async def test_success_zero_remaining_prevents_next_client_request(): + cooldown = ProviderCooldown() + first = source( + lambda request: httpx.Response( + 200, + json=envelope([]), + headers={ + "X-RateLimit-Burst-Remaining": "0", + "X-RateLimit-Burst-Reset": str(time.time() + 60), + }, + ), + cooldown=cooldown, + ) + await first.check() + await first.close() + second = source(lambda request: pytest.fail("cooldown must prevent network"), cooldown=cooldown) + try: + with pytest.raises(MetadataSourceError) as error: + await second.check() + assert error.value.status == SourceStatus.RATE_LIMITED + finally: + await second.close() + + +async def test_cancellation_stops_owned_request_and_does_not_retry(): + entered, exited = asyncio.Event(), asyncio.Event() + + async def handle(request): + entered.set() + try: + await asyncio.Event().wait() + finally: + exited.set() + + client = source(handle) + task = asyncio.create_task(client.check()) + try: + await asyncio.wait_for(entered.wait(), 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert exited.is_set() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await client.close() + + +async def test_registry_constructs_enabled_metron_and_closes_transport(monkeypatch): + requests = [] + closed = [] + + class Transport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request): + requests.append(request) + return httpx.Response(200, json=envelope([series_row()])) + + async def aclose(self): + closed.append(True) + + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kwargs: original(**{**kwargs, "transport": Transport()}), + raising=False, + ) + policy = default_policy(MetadataSource.METRON_API).model_copy(update={"enabled": True}) + result = await MetadataSourceRegistry([SourceRuntime(policy, SecretStr(TOKEN))]).discover( + SeriesDiscoveryQuery(query="Fixture") + ) + assert result.sources[0].status == SourceStatus.OK + assert result.results[0].source == MetadataSource.METRON_API + assert len(requests) == len(closed) == 1 + + +@pytest.mark.parametrize( + "type_id,label", + [ + (12, "Fixture (2024) Digital"), + (10, "Fixture TPB (2024)"), + (8, "Fixture HC (2024)"), + (9, "Fixture GN (2024)"), + ], +) +async def test_series_display_format_is_not_part_of_canonical_title(type_id, label): + client = source( + lambda request: httpx.Response( + 200, + json=envelope( + [ + { + **series_row(), + "series": label, + "series_type": {"id": type_id, "name": "Format"}, + } + ] + ), + ) + ) + try: + result = await client.search(SeriesDiscoveryQuery(query="Fixture"), 0) + assert result.results[0].title == "Fixture" + finally: + await client.close() + + +@pytest.mark.parametrize( + "payload", + [ + b'{"id":7,"id":8,"series":{"id":8},"number":"1"}', + b'{"id":7,"series":{"id":8},"number":"1","page":NaN}', + b'{"id":7,"series":{"id":8},"number":"1","extra":Infinity}', + b"not json", + b"[" * 1200, + ], +) +async def test_ambiguous_or_non_json_success_is_rejected(payload): + client = source( + lambda request: httpx.Response( + 200, content=payload, headers={"Content-Type": "application/json"} + ) + ) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +async def test_duplicate_json_identity_never_selects_the_last_value(): + client = source( + lambda request: httpx.Response( + 200, + content=b'{"id":8,"id":7,"series":{"id":8},"number":"1"}', + headers={"Content-Type": "application/json"}, + ) + ) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +@pytest.mark.parametrize( + "data", + [ + {"id": 8}, + {"cv_id": True}, + {"gcd_id": "4050-1"}, + {"number": None}, + {"cover_date": "2024-13-99"}, + {"modified": "2024-01-01T00:00:00"}, + {"page": -1}, + ], +) +async def test_detail_mismatch_and_bad_metadata_are_not_accepted(data): + client = source(lambda request: httpx.Response(200, json={**issue_row(7), **data})) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +@pytest.mark.parametrize( + "value", + [ + "http://static.metron.cloud/media/x.jpg", + "https://metron.cloud@evil.invalid/media/x.jpg", + "https://evil.invalid/media/x.jpg", + "https://static.metron.cloud/media/%2e%2e/private", + "https://static.metron.cloud/media/x.jpg?token=private", + ], +) +async def test_untrusted_artwork_urls_are_not_exposed(value): + client = source( + lambda request: httpx.Response( + 200, json={**issue_row(7), "image": value, "resource_url": value} + ) + ) + try: + row = (await client.issue("7")).data + assert row.image_url is None and row.resource_url is None + finally: + await client.close() + + +@pytest.mark.parametrize( + "credential,status", + [ + ("", SourceStatus.UNCONFIGURED), + ("enc:private", SourceStatus.INVALID_CONFIG), + ("private\r\nvalue", SourceStatus.INVALID_CONFIG), + ("secret\x7f", SourceStatus.INVALID_CONFIG), + ], +) +async def test_invalid_credentials_never_construct_http_client(credential, status, monkeypatch): + monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: pytest.fail("must not construct")) + with pytest.raises(MetadataSourceError) as error: + MetronSource(SecretStr(credential)) + assert error.value.status == status + + +async def test_invalid_identity_and_validator_are_rejected_before_network(): + client = source(lambda request: pytest.fail("must not request")) + try: + with pytest.raises(ValueError): + await client.issue("../7") + with pytest.raises(ValueError): + await client.issue("7", validator="private\r\nAuthorization: replacement") + with pytest.raises(ValueError): + await client.issues("8", page=0) + with pytest.raises(ValueError): + await client.story_arcs(" ") + finally: + await client.close() + + +async def test_unsolicited_304_is_not_success(): + client = source(lambda request: httpx.Response(304)) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +async def test_stream_size_is_bounded_and_closed(): + closed = [] + + class Stream(httpx.AsyncByteStream): + async def __aiter__(self): + for _ in range(40): + yield b"x" * 65536 + + async def aclose(self): + closed.append(True) + + client = source( + lambda request: httpx.Response( + 200, stream=Stream(), headers={"Content-Type": "application/json"} + ) + ) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + assert closed == [True] + finally: + await client.close() + + +async def test_transport_timeout_and_transient_retry_are_bounded(monkeypatch): + import pullbox.providers.metadata.metron as module + + monkeypatch.setattr(module, "_REQUEST_TIMEOUT", 0.03) + + async def wait(request): + await asyncio.Event().wait() + + client = source(wait) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.TIMEOUT + finally: + await client.close() + requests = [] + + def fail(request): + requests.append(request) + raise httpx.ReadError(TOKEN) + + monkeypatch.setattr(module, "_REQUEST_TIMEOUT", 2) + client = source(fail) + try: + with pytest.raises(MetadataSourceError) as error: + await client.issue("7") + assert error.value.status == SourceStatus.UNAVAILABLE and TOKEN not in str(error.value) + assert len(requests) == 2 + finally: + await client.close() + + +async def test_changed_total_between_search_pages_is_not_a_complete_result(): + def handle(request): + first = request.url.params["page"] == "1" + return httpx.Response( + 200, + json=envelope( + [series_row(i) for i in (range(1, 101) if first else range(101, 111))], + count=130 if first else 110, + next_url="https://metron.cloud/api/series/?name=Fixture&page=2" if first else None, + ), + ) + + client = source(handle) + try: + with pytest.raises(MetadataSourceError) as error: + await client.search(SeriesDiscoveryQuery(query="Fixture", limit_per_source=30), 90) + assert error.value.status == SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await client.close() + + +async def test_default_registry_disabled_and_unconfigured_do_not_construct(monkeypatch): + from pullbox.services.metadata_discovery import describe_source_policies + + monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: pytest.fail("must not construct")) + for enabled, expected in [(False, SourceStatus.DISABLED), (True, SourceStatus.UNCONFIGURED)]: + policy = default_policy(MetadataSource.METRON_API).model_copy(update={"enabled": enabled}) + descriptor = describe_source_policies([policy], gcd_api_enabled=False)[0] + assert descriptor.availability == expected + registry = MetadataSourceRegistry([SourceRuntime(policy)]) + assert (await registry.check(MetadataSource.METRON_API)).status == expected + + +async def test_default_cooldown_survives_client_recreation(): + first = MetronSource( + SecretStr(TOKEN), + transport=httpx.MockTransport( + lambda request: httpx.Response(429, headers={"Retry-After": "60"}) + ), + ) + try: + with pytest.raises(MetadataSourceError): + await first.check() + finally: + await first.close() + second = MetronSource( + SecretStr(TOKEN), + transport=httpx.MockTransport(lambda request: pytest.fail("must not request")), + ) + try: + with pytest.raises(MetadataSourceError) as error: + await second.check() + assert error.value.status == SourceStatus.RATE_LIMITED + finally: + await second.close() + + +async def test_issue_list_parent_kind_cannot_silently_switch_endpoint(): + client = source(lambda request: pytest.fail("must not request")) + try: + with pytest.raises(ValueError): + await client.issues("8", kind="series") + finally: + await client.close() + + +async def test_page_cap_is_explicitly_truncated_not_complete(): + rows = [issue_row(i) for i in range(1, 101)] + client = source( + lambda request: httpx.Response( + 200, + json=envelope( + rows, + count=1000001, + next_url="https://metron.cloud/api/series/8/issue_list/?page=10001", + ), + ) + ) + try: + result = await client.issues("8", page=10000) + assert result.data.next_page is None + assert result.data.truncated is True and result.data.total == 1000001 + finally: + await client.close() + + +async def test_issue_pages_are_conditional_and_preserve_membership(): + seen = [] + + def handle(request): + seen.append(request) + if "if-modified-since" in request.headers: + return httpx.Response(304) + if request.url.params["page"] == "2": + return httpx.Response(200, json=envelope([issue_row(101)], count=101)) + return httpx.Response( + 200, + headers={"Last-Modified": MODIFIED}, + json=envelope( + [issue_row(i) for i in range(1, 101)], + count=101, + next_url="https://metron.cloud/api/series/8/issue_list/?page=2", + ), + ) + + client = source(handle) + try: + first = await client.issues("8") + assert len(first.data.results) == 100 and first.data.next_page == 2 + second = await client.issues("8", page=2) + assert second.data.results[0].external_id == "101" and second.data.next_page is None + cached = await client.issues("8", validator=first.validator) + assert cached.status == SourceStatus.NOT_MODIFIED and cached.data is None + assert len(seen) == 3 + finally: + await client.close() + + +async def test_transient_retry_success_and_cancellation_before_retry(monkeypatch): + import pullbox.providers.metadata.metron as module + + seen = [] + + def handle(request): + seen.append(request) + return httpx.Response(503) if len(seen) == 1 else httpx.Response(200, json=issue_row(7)) + + client = source(handle) + try: + assert (await client.issue("7")).data.external_id == "7" + assert len(seen) == 2 + finally: + await client.close() + + sleeping = asyncio.Event() + + async def blocked_sleep(delay): + sleeping.set() + await asyncio.Event().wait() + + monkeypatch.setattr(module.asyncio, "sleep", blocked_sleep) + seen.clear() + client = source(handle) + task = asyncio.create_task(client.issue("7")) + try: + await asyncio.wait_for(sleeping.wait(), 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(seen) == 1 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await client.close() From 1ce7293838f12fe0f51b1ef87034341cdd399198 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 04:42:35 -0700 Subject: [PATCH 22/97] feat(metadata): add Metron credential settings --- src/pullbox/ui/static/js/metadata-sources.js | 115 ++++++- .../partials/settings_metadata_sources.html | 15 +- .../templates/partials/settings_metron.html | 36 +++ tests/e2e/test_metron_settings.py | 304 ++++++++++++++++++ tests/ui/test_metadata_sources_settings.py | 6 + 5 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 src/pullbox/ui/templates/partials/settings_metron.html create mode 100644 tests/e2e/test_metron_settings.py diff --git a/src/pullbox/ui/static/js/metadata-sources.js b/src/pullbox/ui/static/js/metadata-sources.js index cd64e910..57a62693 100644 --- a/src/pullbox/ui/static/js/metadata-sources.js +++ b/src/pullbox/ui/static/js/metadata-sources.js @@ -9,8 +9,93 @@ function metadataSourceSettings(seed, csrf) { domains: {core: 'Core metadata', issues: 'Issue catalogs', artwork: 'Artwork', story_arcs: 'Story arcs'}, labels, saving: false, testing: null, message: '', error: '', conflict: false, healthMessage: '', controllers: new Set(), alive: true, savedOrder: '', refreshing: false, - init() { this.accept(seed); }, - destroy() { this.alive = false; this.controllers.forEach(controller => controller.abort()); }, + metronBase: null, metronEnabled: false, metronToken: '', metronClear: false, + metronSaving: false, metronError: '', metronMessage: '', metronConflict: false, + healthRefreshPending: false, + init() { this.accept(seed); this.acceptMetron(seed.find(item => item.source === 'metron_api')); }, + destroy() { + this.alive = false; this.metronToken = ''; + this.controllers.forEach(controller => controller.abort()); + }, + get busy() { return this.saving || this.testing || this.refreshing || this.metronSaving; }, + get metronDirty() { + return Boolean(this.metronBase && (this.metronEnabled !== this.metronBase.enabled || + this.metronToken || this.metronClear)); + }, + acceptMetron(item) { + this.metronBase = item ? clone(item) : null; + this.metronEnabled = Boolean(item?.enabled); + this.metronToken = ''; this.metronClear = false; + this.metronError = ''; this.metronMessage = ''; this.metronConflict = false; + }, + async saveMetron() { + if (this.busy || !this.metronDirty) return; + this.metronError = ''; this.metronMessage = ''; + if (this.metronEnabled && this.metronClear) { + this.metronError = 'Disable Metron before removing its saved token.'; + return; + } + if (this.metronEnabled && !this.metronToken && !this.metronBase.credential_configured) { + this.metronError = 'Enter a token before enabling Metron.'; + return; + } + if (this.metronToken && (!/^[\x21-\x7e]+$/.test(this.metronToken) || + this.metronToken.length > 4096 || this.metronToken.startsWith('enc:'))) { + this.metronError = 'Enter the API token from your Metron account, without spaces or line breaks.'; + return; + } + this.metronSaving = true; + const base = clone(this.metronBase); + try { + const response = await this.request('/api/v1/metadata/sources/metron_api', { + method: 'PUT', body: JSON.stringify({ + revision: base.revision, enabled: this.metronEnabled, + priority: base.priority, domain_priorities: base.domain_priorities, settings: base.settings, + ...(this.metronToken && !this.metronClear ? {credential: this.metronToken} : {}), + clear_credential: this.metronClear, + }), + }); + if (!this.alive) return; + if (response.status === 409) { + this.metronConflict = true; + this.metronError = 'Metron settings changed in another session. Your draft is kept. Load saved Metron settings before trying again.'; + return; + } + if (!response.ok) throw new Error('save'); + const policy = response.data; + const current = this.sources.find(item => item.source === 'metron_api'); + const descriptor = {...current, ...policy, availability: policy.configuration_status || + (!policy.enabled ? 'disabled' : !policy.credential_configured ? 'unconfigured' : null)}; + this.sources = this.sources.map(item => item.source === 'metron_api' ? descriptor : item); + // Only advance the priority revision when that draft saw the same policy. + this.savedSources = this.savedSources.map(item => + item.source === 'metron_api' && item.revision === base.revision ? clone(descriptor) : item); + this.acceptMetron(policy); + this.healthMessage = ''; + this.metronMessage = 'Metron settings saved. Connection checks use this saved configuration.'; + } catch (_) { + if (this.alive) this.metronError = 'Could not save Metron settings. Your draft is kept; check the connection and retry.'; + } finally { this.metronSaving = false; this.flushHealthRefresh(); } + }, + async reloadMetron() { + if (this.busy) return; + this.metronSaving = true; + try { + const response = await this.request('/api/v1/metadata/sources'); + if (!response.ok) throw new Error('load'); + if (!this.alive) return; + this.sources = response.data; + this.acceptMetron(response.data.find(item => item.source === 'metron_api')); + } catch (_) { + if (this.alive) this.metronError = 'Could not load Metron settings. Your draft is kept; check the connection and retry.'; + } finally { this.metronSaving = false; this.flushHealthRefresh(); } + }, + flushHealthRefresh() { + if (this.alive && this.healthRefreshPending && !this.busy) { + this.healthRefreshPending = false; + this.refreshHealth(); + } + }, eligible(domain) { return this.order.filter(source => domain !== 'artwork' || !source.startsWith('gcd_')); }, @@ -40,7 +125,7 @@ function metadataSourceSettings(seed, csrf) { return this.signature() !== this.savedOrder; }, move(domain, index, direction) { - if (this.saving) return; + if (this.busy) return; const order = domain === 'global' ? this.order : this.domainOrders[domain]; const target = index + direction; if (!order || target < 0 || target >= order.length) return; @@ -56,13 +141,13 @@ function metadataSourceSettings(seed, csrf) { } }, toggleDomain(domain, enabled) { - if (this.saving) return; + if (this.busy) return; if (enabled) this.domainOrders[domain] = this.eligible(domain); else delete this.domainOrders[domain]; this.message = ''; }, reset() { - if (this.saving) return; + if (this.busy) return; this.accept(this.savedSources); this.message = ''; this.error = ''; this.conflict = false; }, @@ -83,7 +168,7 @@ function metadataSourceSettings(seed, csrf) { } }, async save() { - if (this.saving || this.testing || this.refreshing || !this.dirty) return; + if (this.busy || !this.dirty) return; this.saving = true; this.error = ''; this.message = ''; this.conflict = false; try { const response = await this.request('/api/v1/metadata/priorities', { @@ -100,14 +185,18 @@ function metadataSourceSettings(seed, csrf) { if (!response.ok) throw new Error('save'); const data = response.data; if (!this.alive) return; + const previous = this.savedSources.find(item => item.source === 'metron_api'); + if (this.metronBase?.revision === previous?.revision) { + this.metronBase = clone(data.find(item => item.source === 'metron_api')); + } this.accept(data); this.message = 'Metadata priority saved.'; } catch (_) { if (this.alive) this.error = 'Could not save metadata priority. Your draft is kept; check the connection and retry.'; - } finally { this.saving = false; } + } finally { this.saving = false; this.flushHealthRefresh(); } }, async reload() { - if (this.saving || this.testing) return; + if (this.busy) return; this.saving = true; try { const response = await this.request('/api/v1/metadata/sources'); @@ -117,7 +206,7 @@ function metadataSourceSettings(seed, csrf) { this.accept(data); this.error = ''; this.conflict = false; this.message = ''; } catch (_) { if (this.alive) this.error = 'Could not load saved priority. Your draft is kept; check the connection and retry.'; - } finally { this.saving = false; } + } finally { this.saving = false; this.flushHealthRefresh(); } }, status(item) { if (!item.capabilities.length && item.availability !== 'feature_disabled') return 'Not available in this build'; @@ -133,7 +222,7 @@ function metadataSourceSettings(seed, csrf) { }, canTest(item) { return item.enabled && item.capabilities.length > 0 && !item.availability; }, async refreshHealth() { - if (this.refreshing) return; + if (this.busy) { this.healthRefreshPending = true; return; } this.refreshing = true; try { const response = await this.request('/api/v1/metadata/sources'); @@ -141,10 +230,10 @@ function metadataSourceSettings(seed, csrf) { if (this.alive) this.sources = response.data; } catch (_) { if (this.alive) this.healthMessage = 'Could not refresh source status. Reload Metadata settings to check the saved configuration.'; - } finally { this.refreshing = false; } + } finally { this.refreshing = false; this.flushHealthRefresh(); } }, async test(item) { - if (this.testing || this.saving || this.refreshing || !this.canTest(item)) return; + if (this.busy || !this.canTest(item)) return; this.testing = item.source; this.healthMessage = 'Checking ' + this.labels[item.source] + '...'; try { @@ -163,7 +252,7 @@ function metadataSourceSettings(seed, csrf) { } } catch (_) { if (this.alive) this.healthMessage = 'Could not check ' + this.labels[item.source] + '. Check the connection and retry.'; - } finally { this.testing = null; } + } finally { this.testing = null; this.flushHealthRefresh(); } }, }; } diff --git a/src/pullbox/ui/templates/partials/settings_metadata_sources.html b/src/pullbox/ui/templates/partials/settings_metadata_sources.html index 1b38be14..81717bf3 100644 --- a/src/pullbox/ui/templates/partials/settings_metadata_sources.html +++ b/src/pullbox/ui/templates/partials/settings_metadata_sources.html @@ -1,4 +1,4 @@ -{% from "components/settings_shell.html" import section_card, settings_footer, ui_icon %} +{% from "components/settings_shell.html" import section_card, settings_footer, settings_row, ui_icon %} {% from "components/order_chevrons.html" import order_chevrons %} {% macro priority_list(domain) %} @@ -8,7 +8,7 @@ {{ order_chevrons( up_action="move('" ~ domain ~ "', index, -1)", down_action="move('" ~ domain ~ "', index, 1)", - up_disabled="saving || index === 0", down_disabled="saving || index >= " ~ ('order' if domain == 'global' else 'domainOrders.' ~ domain) ~ ".length - 1", + up_disabled="busy || index === 0", down_disabled="busy || index >= " ~ ('order' if domain == 'global' else 'domainOrders.' ~ domain) ~ ".length - 1", up_label="'Move ' + labels[source] + ' up'", down_label="'Move ' + labels[source] + ' down'" ) }} @@ -29,7 +29,7 @@ {% for domain, label in [('core', 'Core metadata'), ('issues', 'Issue catalogs'), ('artwork', 'Artwork'), ('story_arcs', 'Story arcs')] %}
@@ -43,9 +43,9 @@
{% call settings_footer(align="end") %} {% endcall %} {% endcall %} @@ -60,7 +60,7 @@

- @@ -69,6 +69,7 @@ {% endcall %} +{% include "partials/settings_metron.html" %} ", "language": "en", - "status": "Continuing", + "status": "Ongoing", "resource_url": "https://metron.cloud/series/fixture-2024/", }, ) @@ -164,6 +164,7 @@ def handle(request): try: result = await client.series("0008") assert result.status == SourceStatus.OK and result.data.title == "Fixture (Special)" + assert result.data.status == "continuing" assert result.data.description == "

Good

" and result.validator == MODIFIED unchanged = await client.series("8", validator=result.validator) assert unchanged.status == SourceStatus.NOT_MODIFIED and unchanged.data is None @@ -173,6 +174,59 @@ def handle(request): await client.close() +@pytest.mark.parametrize( + "status,expected", + [ + ("Ongoing", "continuing"), + ("Completed", "ended"), + ("Cancelled", "ended"), + ("Hiatus", "unknown"), + ], +) +async def test_series_lifecycle_is_normalized_for_library_adoption(status, expected): + client = source( + lambda request: httpx.Response( + 200, json={**series_row(8), "name": "Fixture", "status": status} + ) + ) + try: + assert (await client.series("8")).data.status == expected + finally: + await client.close() + + +@pytest.mark.parametrize( + "type_id,expected", + [ + (5, "one_shot"), + (6, "annual"), + (8, "hardcover"), + (9, "graphic_novel"), + (10, "tpb"), + (11, "standard"), + (12, "standard"), + (13, "standard"), + (14, "omnibus"), + (999, None), + ], +) +async def test_series_format_is_normalized_for_library_adoption(type_id, expected): + client = source( + lambda request: httpx.Response( + 200, + json={ + **series_row(8), + "name": "Fixture", + "series_type": {"id": type_id, "name": "Provider format"}, + }, + ) + ) + try: + assert (await client.series("8")).data.series_type == expected + finally: + await client.close() + + @pytest.mark.parametrize( "number,key", [("50-x", "50-X"), ("13a", "13A"), ("-1", "-1"), ("0.5", "0.5"), ("Annual", None)] ) From 2116c1e6cb654927b1a1257c11a6b10b10b36248 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 06:23:44 -0700 Subject: [PATCH 25/97] feat(metadata): add source-aware series creation workflow --- src/pullbox/api/v1/series.py | 38 ++- src/pullbox/core/events.py | 2 +- src/pullbox/schemas/series.py | 31 +- src/pullbox/services/metadata_series_add.py | 85 +++++ src/pullbox/services/metadata_service.py | 51 +-- src/pullbox/services/series_service.py | 84 ++++- tests/api/test_source_series_add_api.py | 203 ++++++++++++ .../test_series_add_workflow.py | 296 ++++++++++++++++++ 8 files changed, 748 insertions(+), 42 deletions(-) create mode 100644 src/pullbox/services/metadata_series_add.py create mode 100644 tests/api/test_source_series_add_api.py create mode 100644 tests/integration/metadata_identity/test_series_add_workflow.py diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py index bc23e6ef..1af0cd0f 100644 --- a/src/pullbox/api/v1/series.py +++ b/src/pullbox/api/v1/series.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import contains_eager from pullbox.api.deps import AuthenticatedUser, DbSession +from pullbox.config import get_settings from pullbox.core.events import IssueWanted, get_event_bus from pullbox.core.exceptions import NotFoundError, ValidationError from pullbox.core.library_policy import load_search_on_add_default @@ -28,9 +29,17 @@ SeriesListResponse, SeriesResponse, SeriesUpdate, + SourceSeriesCreate, ) from pullbox.services.cover_url_service import build_series_cover_url +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_series_add import source_series_add_transaction +from pullbox.services.metadata_series_adoption import ( + SeriesAdoptionError, + fetch_source_series_bundle, +) from pullbox.services.metadata_service import MetadataService +from pullbox.services.metadata_sources import load_source_runtime from pullbox.services.search_targets import load_series_wanted_search_targets from pullbox.services.series_service import SeriesService from pullbox.tasks.search_task import search_series_issues @@ -372,15 +381,40 @@ async def get_series( @router.post("", response_model=SeriesResponse, status_code=201) async def add_series( - body: SeriesCreate, + body: SeriesCreate | SourceSeriesCreate, _user: AuthenticatedUser, session: DbSession, ) -> SeriesResponse: - """Add a series to the library from ComicVine.""" + """Add a source-selected series, retaining the legacy ComicVine request.""" search_on_add = await load_search_on_add_default(session) if body.search_on_add is not None and body.search_on_add != search_on_add: raise ValidationError("Search on add is now controlled by the global import policy.") + if isinstance(body, SourceSeriesCreate): + gcd_enabled = get_settings().metadata_gcd_api_v2_enabled + runtime = await load_source_runtime(session, gcd_api_enabled=gcd_enabled) + await session.rollback() + try: + bundle = await fetch_source_series_bundle( + MetadataSourceRegistry(runtime, gcd_api_enabled=gcd_enabled), + body.source, + body.external_id, + source_revision=body.source_revision, + ) + # This command owns commit/cleanup; serialize before committing so + # a failed response cannot leave an unexpected library addition. + async with source_series_add_transaction( + session, + bundle, + library_root_id=body.library_root_id, + search_on_add=search_on_add, + event_bus=get_event_bus(), + ) as result: + response = await _load_series_response(session, result.series.id) + return response + except SeriesAdoptionError as exc: + raise HTTPException(409, str(exc)) from exc + series_svc = await _build_series_service(session) series = await series_svc.add_from_comicvine( diff --git a/src/pullbox/core/events.py b/src/pullbox/core/events.py index ca561844..1baf9bcc 100644 --- a/src/pullbox/core/events.py +++ b/src/pullbox/core/events.py @@ -76,7 +76,7 @@ class SeriesAdded: """Emitted when a new series is added to the library.""" series_id: int - comicvine_id: int + comicvine_id: int | None = None @dataclass(frozen=True) diff --git a/src/pullbox/schemas/series.py b/src/pullbox/schemas/series.py index c2196aa8..c8b67319 100644 --- a/src/pullbox/schemas/series.py +++ b/src/pullbox/schemas/series.py @@ -2,8 +2,9 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource from pullbox.models.series import ( IssueCatalogState, SeriesStatus, @@ -27,6 +28,34 @@ class SeriesCreate(BaseModel): ), ) + @model_validator(mode="before") + @classmethod + def reject_mixed_source_identity(cls, value: object) -> object: + if isinstance(value, dict) and {"source", "external_id", "source_revision"}.intersection( + value + ): + raise ValueError("Choose a source identity or a legacy ComicVine ID, not both.") + return value + + +class SourceSeriesCreate(BaseModel): + """Select a source identity; metadata is always fetched by the server.""" + + model_config = ConfigDict(extra="forbid") + + source: MetadataSource + external_id: str = Field(..., min_length=1, max_length=320) + source_revision: int = Field(..., ge=0, strict=True) + library_root_id: int | None = Field(None, gt=0) + search_on_add: bool | None = None + + @model_validator(mode="after") + def canonical_identity(self) -> "SourceSeriesCreate": + self.external_id = ExternalIdentityRef( + self.source.identity_namespace, MetadataEntityKind.SERIES, self.external_id + ).external_id + return self + class SeriesUpdate(BaseModel): """Request body for updating a series.""" diff --git a/src/pullbox/services/metadata_series_add.py b/src/pullbox/services/metadata_series_add.py new file mode 100644 index 00000000..fed4d79a --- /dev/null +++ b/src/pullbox/services/metadata_series_add.py @@ -0,0 +1,85 @@ +"""Transaction-owned Add Series command for a server-fetched source catalog.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.events import EventBus, SeriesAdded +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind +from pullbox.services.cover_cache_service import purge_series_cover_cache +from pullbox.services.metadata_series_adoption import ( + SeriesAdoptionResult, + SourceSeriesBundle, + adopt_source_series_bundle, +) +from pullbox.services.metadata_service import ( + MetadataService, + classify_issue_metadata, + infer_series_type_from_issue_evidence, +) +from pullbox.services.series_service import ( + SeriesFolderCreationResult, + SeriesService, + rollback_series_folder_creation, +) + + +@asynccontextmanager +async def source_series_add_transaction( + session: AsyncSession, + bundle: SourceSeriesBundle, + *, + library_root_id: int | None, + search_on_add: bool, + event_bus: EventBus, +) -> AsyncIterator[SeriesAdoptionResult]: + """Own this add transaction, including response construction before commit. + + The caller releases its read snapshot before fetching the bundle. This is an + explicit lifecycle boundary, not a flush-only service or a nested transaction. + Only newly created directories are eligible for rollback cleanup. + """ + if session.in_transaction(): + raise ValueError("Source Add requires a session without an active transaction") + folder: SeriesFolderCreationResult | None = None + try: + async with session.begin(): + result = await adopt_source_series_bundle(session, bundle, monitored=search_on_add) + if result.created: + series = result.series + infer_series_type_from_issue_evidence( + series, + [ + classify_issue_metadata(series.series_type, issue.title)[0] + for issue in bundle.issues + ], + ) + await MetadataService.classify_and_link_series( + session, + series, + preserve_provider_type=bundle.series.series_type is not None, + ) + if library_root_id is not None: + folder = await SeriesService._create_series_folder( + session, + series, + library_root_id, + series.comicvine_id, + source_identity=ExternalIdentityRef( + bundle.series.identity_namespace, + MetadataEntityKind.SERIES, + bundle.series.external_id, + ), + ) + await purge_series_cover_cache(session, series.id) + await session.flush() + yield result + except BaseException: + if folder is not None: + rollback_series_folder_creation(folder) + raise + if result.created: + await event_bus.emit( + SeriesAdded(series_id=result.series.id, comicvine_id=result.series.comicvine_id) + ) diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py index 3d5948a0..f191c0ff 100644 --- a/src/pullbox/services/metadata_service.py +++ b/src/pullbox/services/metadata_service.py @@ -152,6 +152,23 @@ def _series_type_from_complete_issue_evidence( return None +def infer_series_type_from_issue_evidence(series: Series, issue_types: list[IssueType]) -> None: + """Apply complete explicit issue evidence, never inherited or partial evidence.""" + if ( + classify_series_type( + series.title, + description=series.description, + issue_count=series.issue_count, + year_start=series.year_start, + ) + != SeriesType.STANDARD.value + ): + return + inferred = _series_type_from_complete_issue_evidence(issue_types) + if inferred is not None: + series.series_type = inferred + + class MetadataService: """Orchestrates metadata fetching and caching. @@ -457,10 +474,12 @@ async def get_recent_issue_summaries_for_series( except ComicVineError as exc: raise _provider_error_from_comicvine(exc) from exc + @staticmethod async def classify_and_link_series( - self, session: AsyncSession, series: Series, + *, + preserve_provider_type: bool = False, ) -> None: """Detect series type from CV title and link to parent series. @@ -477,6 +496,8 @@ async def classify_and_link_series( year_start=series.year_start, ) ) + if preserve_provider_type and previous_type != SeriesType.STANDARD: + detected = previous_type if detected == SeriesType.STANDARD and previous_type != SeriesType.STANDARD: issue_types = [ IssueType(detect_issue_type_from_metadata_title(title)) @@ -494,7 +515,7 @@ async def classify_and_link_series( # summary fields that are not represented in local prose. detected = previous_type series.series_type = detected - await self._repair_issue_types_after_series_change( + await MetadataService._repair_issue_types_after_series_change( session, series, previous_type=previous_type, @@ -511,7 +532,7 @@ async def classify_and_link_series( return # Couldn't extract a different base title # Search for a standard parent series with the base title + similar year - parent = await self._find_parent_series( + parent = await MetadataService._find_parent_series( session, base_title, series.year_start, @@ -996,28 +1017,8 @@ async def _upsert_issue_summaries( # Only a complete catalog where every summary has explicit non-standard # evidence may infer the series type. Recent/targeted subsets and a mix # of ordinary plus special issues must never reclassify the parent. - if ( - infer_series_type_from_summaries - and summaries - and classify_series_type( - series.title, - description=series.description, - issue_count=series.issue_count, - year_start=series.year_start, - ) - == SeriesType.STANDARD.value - ): - inferred_series_type = _series_type_from_complete_issue_evidence(summary_evidence_types) - if inferred_series_type is not None: - series.series_type = inferred_series_type - log.debug( - "series_type_inferred_from_issues", - series_id=series_id, - series_type=inferred_series_type.value, - source_issue_types=sorted( - {issue_type.value for issue_type in summary_evidence_types} - ), - ) + if infer_series_type_from_summaries and summaries: + infer_series_type_from_issue_evidence(series, summary_evidence_types) log.debug( "metadata_issues_synced", diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py index 7e81cb80..9a2bee38 100644 --- a/src/pullbox/services/series_service.py +++ b/src/pullbox/services/series_service.py @@ -63,6 +63,7 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession + from pullbox.core.metadata_identity import ExternalIdentityRef from pullbox.models.import_job import ImportedSeries from pullbox.services.metadata_service import MetadataService @@ -76,6 +77,7 @@ class SeriesFolderCreationResult: folder_path: Path ownership_boundary_path: Path created_directory_paths: tuple[Path, ...] + created_directory_identities: tuple[tuple[Path, int, int], ...] = () def ownership_payload(self) -> dict[str, object]: return { @@ -89,6 +91,8 @@ def ownership_payload(self) -> dict[str, object]: def _create_series_folder_directories( folder_path: Path, ownership_boundary_path: Path, + *, + exclusive_leaf: bool = False, ) -> tuple[Path, ...]: """Create and return only strict descendant directory segments created here.""" relative = folder_path.relative_to(ownership_boundary_path) @@ -107,6 +111,8 @@ def _create_series_folder_directories( try: current.mkdir() except FileExistsError: + if exclusive_leaf and current == folder_path: + raise if not current.is_dir(): raise else: @@ -127,6 +133,56 @@ def _create_series_folder_directories( return tuple(created) +def rollback_series_folder_creation(result: SeriesFolderCreationResult) -> None: + """Remove only still-owned, empty directories; never remove library contents.""" + for path, device, inode in reversed(result.created_directory_identities): + try: + if path.is_symlink() or path.resolve(strict=True) != path: + continue + path.relative_to(result.ownership_boundary_path) + current = path.stat() + if path != result.ownership_boundary_path and (current.st_dev, current.st_ino) == ( + device, + inode, + ): + path.rmdir() + except OSError: + logger.debug("series_folder_rollback_retained_directory", path=str(path)) + except ValueError: + logger.warning("series_folder_rollback_boundary_changed", path=str(path)) + + +async def _create_series_folder_owned( + folder_path: Path, root_path: Path, *, exclusive_leaf: bool +) -> SeriesFolderCreationResult: + def create() -> SeriesFolderCreationResult: + paths = _create_series_folder_directories( + folder_path, root_path, exclusive_leaf=exclusive_leaf + ) + return SeriesFolderCreationResult( + folder_path.resolve(strict=True), + root_path.resolve(strict=True), + paths, + tuple((path, stat.st_dev, stat.st_ino) for path in paths for stat in [path.stat()]), + ) + + # Cancellation cannot stop mkdir in a worker. Collect its receipt before cleanup. + worker = asyncio.create_task(asyncio.to_thread(create)) + try: + return await asyncio.shield(worker) + except asyncio.CancelledError: + while not worker.done(): + try: + await asyncio.shield(worker) + except asyncio.CancelledError: + continue + except Exception: + break + if not worker.cancelled() and worker.exception() is None: + rollback_series_folder_creation(worker.result()) + raise + + def _targeted_import_folder_type_hint( series: Series, issue_summaries: list[IssueSummary], @@ -501,14 +557,15 @@ async def _load_naming_config( "colon_replacement": policy.colon_replacement, } + @staticmethod async def _create_series_folder( - self, session: AsyncSession, series: Series, library_root_id: int, - comicvine_id: int, + comicvine_id: int | None, *, folder_series_type: SeriesType | None = None, + source_identity: ExternalIdentityRef | None = None, ) -> SeriesFolderCreationResult: """Create a series folder on disk inside the given library root. @@ -522,7 +579,7 @@ async def _create_series_folder( await validate_managed_library_root(root) # Load naming config - cfg = await self._load_naming_config(session, root) + cfg = await SeriesService._load_naming_config(session, root) # Resolve publisher name for the template if series.publisher_id: @@ -539,7 +596,7 @@ async def _create_series_folder( folder_path = root_path / folder_relative # Reclaim empty/progress-only collision folders left behind by prior interrupted work. - if folder_path.exists(): + if source_identity is None and folder_path.exists(): reclaimed = await asyncio.to_thread(reclaim_transient_series_folder, folder_path) if reclaimed: logger.warning( @@ -549,8 +606,13 @@ async def _create_series_folder( ) # Handle collision: append [cv-{id}] if folder already exists - if folder_path.exists(): - folder_name_cv = f"{folder_path.name} [cv-{comicvine_id}]" + if folder_path.exists() or folder_path.is_symlink(): + suffix = ( + f"{source_identity.namespace.value}-{source_identity.external_id}" + if source_identity is not None and source_identity.namespace.value != "comicvine" + else f"cv-{comicvine_id}" + ) + folder_name_cv = f"{folder_path.name} [{suffix}]" folder_path = folder_path.with_name(folder_name_cv) logger.info( "series_folder_collision", @@ -562,10 +624,10 @@ async def _create_series_folder( # Create each missing segment independently so import rollback can # distinguish directories created here from pre-existing user paths. try: - created_directory_paths = await asyncio.to_thread( - _create_series_folder_directories, + receipt = await _create_series_folder_owned( folder_path, root_path, + exclusive_leaf=source_identity is not None, ) except (OSError, ValueError) as exc: raise ValidationError("Series folder must be created inside its library root") from exc @@ -580,11 +642,7 @@ async def _create_series_folder( series_title=series.title, library_root=root.name, ) - return SeriesFolderCreationResult( - folder_path=folder_path.resolve(strict=True), - ownership_boundary_path=root_path.resolve(strict=True), - created_directory_paths=created_directory_paths, - ) + return receipt @staticmethod async def _build_series_folder_name( diff --git a/tests/api/test_source_series_add_api.py b/tests/api/test_source_series_add_api.py new file mode 100644 index 00000000..64ed1966 --- /dev/null +++ b/tests/api/test_source_series_add_api.py @@ -0,0 +1,203 @@ +"""Source-aware Add Series uses server metadata, real auth and committed side effects.""" + +import os +import sys +from pathlib import Path + +import httpx +import pytest +from sqlalchemy import func, select + +from pullbox.core.events import EventBus, SeriesAdded +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models import Issue, Series +from pullbox.models.config import SystemConfig +from pullbox.models.library import LibraryRoot +from pullbox.schemas.metadata_sources import SourceCapability +from pullbox.services.metadata_discovery import SourceRegistration +from tests.api.test_metadata_sources_api import csrf, policy +from tests.unit.test_metron_source import envelope, issue_row, series_row, source + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +pytest_plugins = ["conftest_security"] + + +@pytest.fixture +async def source_add_setup(authenticated_client, sec_db, monkeypatch, tmp_path): + from pullbox.api.v1 import series as routes + from pullbox.providers.metadata import sources + + saved = await authenticated_client.put( + "/api/v1/metadata/sources/metron_api", + json=policy(credential="synthetic-token"), + headers=csrf(authenticated_client), + ) + assert saved.status_code == 200 + root_path = tmp_path / "managed" + root_path.mkdir() + async with sec_db.begin() as session: + root = LibraryRoot(name="Test", path=str(root_path), allow_managed_writes=True) + session.add(root) + session.add(SystemConfig(key="search_on_add_default", value="true")) + await session.flush() + root_id = root.id + calls = [] + responses = [] + + def handle(request): + calls.append(request) + if request.url.path == "/api/series/8/": + return httpx.Response( + 200, json={**series_row(8), "name": "Fixture 8", "cv_id": None, "gcd_id": None} + ) + assert request.url.path == "/api/series/8/issue_list/" + if responses: + return responses.pop(0) + return httpx.Response( + 200, + json=envelope( + [ + issue_row(100, "13a"), + issue_row(101, "13b"), + issue_row(102, "50-x"), + ] + ), + ) + + original = sources.metadata_sources + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: { + **original(), + MetadataSource.METRON_API: SourceRegistration( + frozenset({SourceCapability.SERIES_DETAILS, SourceCapability.ISSUE_LIST}), + lambda _: source(handle), + ), + }, + ) + events = [] + bus = EventBus() + + async def observe(event): + async with sec_db() as session: + found = await session.get(Series, event.series_id) + assert found is not None and Path(found.path).is_dir() + events.append(event) + + bus.subscribe(SeriesAdded, observe) + monkeypatch.setattr(routes, "get_event_bus", lambda: bus) + + async def no_legacy(_session): + pytest.fail("A source Add must not construct a ComicVine service") + + monkeypatch.setattr(routes, "_build_series_service", no_legacy) + + async def covers_dir(_session): + return tmp_path / "covers" + + monkeypatch.setattr("pullbox.services.cover_cache_service.resolve_covers_dir", covers_dir) + return { + "body": { + "source": "metron_api", + "external_id": "8", + "source_revision": 1, + "library_root_id": root_id, + }, + "calls": calls, + "responses": responses, + "events": events, + "root": root_path, + } + + +async def test_add_native_series_uses_full_server_catalog_and_is_repeat_safe( + authenticated_client, + source_add_setup, + sec_db, +): + fixture = source_add_setup + response = await authenticated_client.post( + "/api/v1/series", json=fixture["body"], headers=csrf(authenticated_client) + ) + assert response.status_code == 201, response.text + data = response.json() + assert data["comicvine_id"] is None and data["title"] == "Fixture 8" + assert data["issue_count"] == 3 and data["issue_catalog_state"] == "complete" + assert data["wanted_count"] == 3 and data["monitored"] is True + assert Path(data["path"]).is_dir() and len(fixture["events"]) == 1 + again = await authenticated_client.post( + "/api/v1/series", json=fixture["body"], headers=csrf(authenticated_client) + ) + assert again.status_code == 201 and again.json()["id"] == data["id"] + assert len(fixture["events"]) == 1 + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(Series)) == 1 + assert await session.scalar(select(func.count()).select_from(Issue)) == 3 + + +@pytest.mark.parametrize( + "changes", + [ + {"source_revision": 0}, + {"source_revision": 99}, + ], +) +async def test_stale_selection_rejects_before_provider_calls( + authenticated_client, source_add_setup, changes +): + fixture = source_add_setup + response = await authenticated_client.post( + "/api/v1/series", json={**fixture["body"], **changes}, headers=csrf(authenticated_client) + ) + assert response.status_code == 409 + assert not fixture["calls"] and not list(fixture["root"].iterdir()) + + +@pytest.mark.parametrize( + "changes", + [ + {"title": "Injected"}, + {"comicvine_id": 999}, + {"external_id": "../8"}, + {"source_revision": True}, + {"issues": []}, + {"source": "locg"}, + ], +) +async def test_rejects_client_metadata_or_ambiguous_identity( + authenticated_client, source_add_setup, changes +): + fixture = source_add_setup + response = await authenticated_client.post( + "/api/v1/series", json={**fixture["body"], **changes}, headers=csrf(authenticated_client) + ) + assert response.status_code == 422 + assert not fixture["calls"] + + +async def test_failed_catalog_does_not_create_partial_library( + authenticated_client, source_add_setup, sec_db +): + fixture = source_add_setup + fixture["responses"].append(httpx.Response(404)) + response = await authenticated_client.post( + "/api/v1/series", json=fixture["body"], headers=csrf(authenticated_client) + ) + assert response.status_code == 409 + assert not fixture["events"] and not list(fixture["root"].iterdir()) + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(Series)) == 0 + + +async def test_add_requires_auth_and_csrf( + authenticated_client, unauthenticated_client, source_add_setup +): + fixture = source_add_setup + assert ( + await unauthenticated_client.post("/api/v1/series", json=fixture["body"]) + ).status_code in {401, 403} + assert ( + await authenticated_client.post("/api/v1/series", json=fixture["body"]) + ).status_code == 403 + assert not fixture["calls"] diff --git a/tests/integration/metadata_identity/test_series_add_workflow.py b/tests/integration/metadata_identity/test_series_add_workflow.py new file mode 100644 index 00000000..6936b8a4 --- /dev/null +++ b/tests/integration/metadata_identity/test_series_add_workflow.py @@ -0,0 +1,296 @@ +"""Real catalog adoption plus filesystem and transaction boundaries on both DBs.""" + +import asyncio +from dataclasses import replace +from pathlib import Path + +import httpx +import pytest +from sqlalchemy import func, select + +from pullbox.core.events import EventBus, SeriesAdded +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, Series +from pullbox.models.config import SystemConfig +from pullbox.models.library import LibraryRoot +from pullbox.models.metadata_identity import SeriesIdentityEvent +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import SeriesType +from pullbox.schemas.metadata_sources import SourceCapability +from pullbox.services.metadata_discovery import MetadataSourceRegistry, SourceRegistration +from pullbox.services.metadata_series_add import source_series_add_transaction +from pullbox.services.metadata_series_adoption import fetch_source_series_bundle +from tests.integration.metadata_identity.test_series_adoption import bundle +from tests.unit.test_metadata_discovery import runtime +from tests.unit.test_metron_source import envelope, issue_row, series_row, source + + +@pytest.fixture +async def add_setup(identity_probe_db, tmp_path, monkeypatch): + _, factory, _ = identity_probe_db + root_path = tmp_path / "library" + root_path.mkdir() + async with factory.begin() as session: + session.add(MetadataSourceConfig(source="metron_api", enabled=True, priority=2, revision=1)) + root = LibraryRoot(name="Test library", path=str(root_path), allow_managed_writes=True) + session.add(root) + session.add( + SystemConfig(key="series_folder_template", value="{Publisher}/{Series} ({Year})") + ) + await session.flush() + root_id = root.id + + async def covers_dir(_session): + return tmp_path / "covers" + + monkeypatch.setattr("pullbox.services.cover_cache_service.resolve_covers_dir", covers_dir) + events = [] + bus = EventBus() + + async def recorded(event): + async with factory() as session: + assert await session.get(Series, event.series_id) is not None + events.append(event) + + bus.subscribe(SeriesAdded, recorded) + return factory, root_id, root_path, bus, events + + +def titled_bundle(title="Source series", *, publisher="Fixture Press", **updates): + result = bundle() + return replace( + result, + series=result.series.model_copy( + update={ + "title": title, + "sort_title": title, + "year_start": 2024, + "publisher": publisher, + **updates, + } + ), + ) + + +async def test_http_registry_to_committed_series_and_event_without_comicvine(add_setup): + factory, root_id, root_path, bus, events = add_setup + requests = [] + async with factory() as session: + + def handle(request): + assert not session.in_transaction(), "Provider I/O must not hold a DB transaction" + requests.append(request) + if request.url.path == "/api/series/8/": + return httpx.Response( + 200, json={**series_row(8), "name": "Fixture 8", "cv_id": None, "gcd_id": None} + ) + assert request.url.path == "/api/series/8/issue_list/" + return httpx.Response( + 200, + json=envelope( + [ + issue_row(100, "13a"), + issue_row(101, "13b"), + issue_row(102, "50-x"), + ] + ), + ) + + registry = MetadataSourceRegistry( + [runtime(Source.METRON_API, revision=1)], + factories={ + Source.METRON_API: SourceRegistration( + frozenset( + { + SourceCapability.SERIES_DETAILS, + SourceCapability.ISSUE_LIST, + } + ), + lambda _: source(handle), + ), + }, + ) + fetched = await fetch_source_series_bundle( + registry, Source.METRON_API, "8", source_revision=1 + ) + async with source_series_add_transaction( + session, fetched, library_root_id=root_id, search_on_add=True, event_bus=bus + ) as result: + assert result.created + series_id = result.series.id + assert not events + assert result.series.path == str(root_path / "Fixture Press/Fixture 8 (2024)") + assert result.series.comicvine_id is None + assert not session.in_transaction() + assert len(requests) == 2 and len(events) == 1 + assert events[0].series_id == series_id and events[0].comicvine_id is None + async with factory() as session: + issues = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert [issue.issue_number_text for issue in issues] == ["13A", "13B", "50-X"] + assert all(issue.status.value == "wanted" for issue in issues) + + +@pytest.mark.parametrize("failure", ["response", "commit", "cancel"]) +async def test_failure_removes_only_created_directories_and_no_event( + add_setup, monkeypatch, failure +): + factory, root_id, root_path, bus, events = add_setup + publisher = root_path / "Fixture Press" + publisher.mkdir() + marker = publisher / "unrelated.cbz" + marker.write_bytes(b"untouched") + async with factory() as session: + if failure == "commit": + from sqlalchemy import event + + def reject_commit(sync_session): + if not sync_session.in_nested_transaction(): + raise RuntimeError("commit failed") + + event.listen(session.sync_session, "before_commit", reject_commit) + expected = asyncio.CancelledError if failure == "cancel" else RuntimeError + with pytest.raises(expected): + async with source_series_add_transaction( + session, + titled_bundle(), + library_root_id=root_id, + search_on_add=False, + event_bus=bus, + ) as result: + assert result.series.path is not None + assert Path(result.series.path).is_dir() + if failure == "cancel": + raise asyncio.CancelledError + if failure == "response": + raise RuntimeError("response failed") + assert list(publisher.iterdir()) == [marker] and marker.read_bytes() == b"untouched" + assert not events + async with factory() as session: + for model in (Series, Issue, SeriesIdentityEvent): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + + +async def test_existing_owner_is_not_moved_or_remonitored(add_setup): + factory, root_id, root_path, bus, events = add_setup + async with factory() as session: + async with source_series_add_transaction( + session, titled_bundle(), library_root_id=None, search_on_add=False, event_bus=bus + ) as result: + series_id = result.series.id + async with session.begin(): + result.series.path = "/existing/reference/keep" + result.series.title = "Operator title" + async with source_series_add_transaction( + session, titled_bundle(), library_root_id=root_id, search_on_add=True, event_bus=bus + ) as result: + assert not result.created and result.series.id == series_id + assert result.series.title == "Operator title" and not result.series.monitored + assert result.series.path == "/existing/reference/keep" + assert len(events) == 1 and not list(root_path.iterdir()) + + +async def test_existing_unregistered_folders_are_preserved_with_native_suffix(add_setup): + factory, root_id, root_path, bus, events = add_setup + existing = root_path / "Fixture Press/Source series (2024)" + existing.mkdir(parents=True) + async with ( + factory() as session, + source_series_add_transaction( + session, titled_bundle(), library_root_id=root_id, search_on_add=False, event_bus=bus + ) as result, + ): + assert result.series.path == str(existing.with_name(existing.name + " [metron-42]")) + assert existing.is_dir() and len(events) == 1 + + +async def test_taken_native_suffix_does_not_merge_into_unowned_directory(add_setup): + factory, root_id, root_path, bus, events = add_setup + existing = root_path / "Fixture Press/Source series (2024)" + existing.mkdir(parents=True) + collision = existing.with_name(existing.name + " [metron-42]") + collision.mkdir() + async with factory() as session: + with pytest.raises(ValidationError): + async with source_series_add_transaction( + session, + titled_bundle(), + library_root_id=root_id, + search_on_add=False, + event_bus=bus, + ): + pass + assert existing.is_dir() and collision.is_dir() and not events + + +@pytest.mark.parametrize("flag", ["enabled", "allow_managed_writes"]) +async def test_root_policy_rejection_does_not_leave_catalog(add_setup, flag): + factory, root_id, root_path, bus, events = add_setup + async with factory.begin() as session: + root = await session.get(LibraryRoot, root_id) + setattr(root, flag, False) + async with factory() as session: + with pytest.raises(ValidationError): + async with source_series_add_transaction( + session, + titled_bundle(), + library_root_id=root_id, + search_on_add=False, + event_bus=bus, + ): + pass + assert not list(root_path.iterdir()) and not events + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Series)) == 0 + + +async def test_active_caller_transaction_is_never_committed(add_setup): + factory, root_id, _, bus, events = add_setup + async with factory() as session: + await session.get(LibraryRoot, root_id) + with pytest.raises(ValueError, match="transaction"): + async with source_series_add_transaction( + session, + titled_bundle(), + library_root_id=root_id, + search_on_add=False, + event_bus=bus, + ): + pass + assert session.in_transaction() and not events + + +async def test_complete_collection_consensus_and_parent_linking_precede_folder_naming(add_setup): + factory, root_id, _, bus, _ = add_setup + async with factory.begin() as session: + parent = Series(title="Source series", sort_title="Source series", year_start=2024) + session.add(parent) + await session.flush() + parent_id = parent.id + data = titled_bundle("Source series Annual", series_type="annual") + async with ( + factory() as session, + source_series_add_transaction( + session, data, library_root_id=root_id, search_on_add=False, event_bus=bus + ) as result, + ): + assert result.series.parent_series_id == parent_id + assert result.series.series_type is SeriesType.ANNUAL + data = titled_bundle() + data = replace( + data, issues=tuple(issue.model_copy(update={"title": "Omnibus"}) for issue in data.issues) + ) + async with factory() as session: + # A different source series, not a refresh of the annual above. + data = replace( + data, + series=data.series.model_copy(update={"external_id": "99"}), + issues=tuple( + issue.model_copy(update={"external_id": str(500 + i), "series_external_id": "99"}) + for i, issue in enumerate(data.issues) + ), + ) + async with source_series_add_transaction( + session, data, library_root_id=None, search_on_add=False, event_bus=bus + ) as result: + assert result.series.series_type is SeriesType.OMNIBUS From 3fe1b4ac4ec1a9cba345e867ab0eaedee37fc799 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 07:35:25 -0700 Subject: [PATCH 26/97] feat(metadata): add provider-neutral artwork downloads Download public ComicVine and Metron artwork without provider credentials, validate and bound image payloads, and stage covers until the current issue identity is rechecked. Preserve exact issue designations in cover cache paths to avoid suffix and decimal collisions. --- src/pullbox/api/v1/covers.py | 10 +- src/pullbox/core/subscribers.py | 108 ++++--- src/pullbox/services/provider_artwork.py | 200 ++++++++++++ tests/unit/test_core_subscribers.py | 26 +- tests/unit/test_provider_artwork.py | 287 ++++++++++++++++++ .../unit/test_provider_artwork_subscriber.py | 155 ++++++++++ .../test_subscriber_transaction_boundaries.py | 13 +- 7 files changed, 739 insertions(+), 60 deletions(-) create mode 100644 src/pullbox/services/provider_artwork.py create mode 100644 tests/unit/test_provider_artwork.py create mode 100644 tests/unit/test_provider_artwork_subscriber.py diff --git a/src/pullbox/api/v1/covers.py b/src/pullbox/api/v1/covers.py index 0f438bc9..59cf6d17 100644 --- a/src/pullbox/api/v1/covers.py +++ b/src/pullbox/api/v1/covers.py @@ -174,14 +174,14 @@ async def get_issue_cover( if issue is None: raise NotFoundError("Issue", issue_id) - # Format issue number for filename (e.g. 1.0 → "001", 1.5 → "001.5") - num = issue.issue_number - issue_num_str = f"{int(num):03d}" if num == int(num) else f"{num:06.1f}" + from pullbox.services.provider_artwork import issue_cover_stem + + stem = issue_cover_stem(issue.issue_number, issue.issue_number_text) # 1. Try series folder (by issue number — human-readable, survives DB rebuilds) if issue.series and issue.series.path: series_path = Path(issue.series.path) - cover = _find_cover_file(series_path, f"issue_{issue_num_str}") + cover = _find_cover_file(series_path, stem) if cover: return _serve_issue_image(cover) @@ -191,7 +191,7 @@ async def get_issue_cover( covers_base = await resolve_covers_dir(session) if issue.series: covers_dir = covers_base / str(issue.series_id) - cover = _find_cover_file(covers_dir, f"issue_{issue_num_str}") + cover = _find_cover_file(covers_dir, stem) if cover: return _serve_issue_image(cover) diff --git a/src/pullbox/core/subscribers.py b/src/pullbox/core/subscribers.py index 1714b4bd..19f46e70 100644 --- a/src/pullbox/core/subscribers.py +++ b/src/pullbox/core/subscribers.py @@ -145,10 +145,12 @@ async def _download_covers_for_series(event: SeriesAdded) -> None: """ from sqlalchemy import select - from pullbox.core.comicvine_key import get_comicvine_api_key from pullbox.models.series import Series - from pullbox.providers.metadata.comicvine import ComicVineProvider - from pullbox.services.metadata_service import MetadataService + from pullbox.services.provider_artwork import ( + ProviderArtworkClient, + issue_cover_stem, + pending_provider_cover, + ) log = logger.bind(series_id=event.series_id) log.info("subscriber_cover_download_start") @@ -166,21 +168,23 @@ async def _download_covers_for_series(event: SeriesAdded) -> None: log.warning("subscriber_cover_download_series_not_found") return - api_key = await get_comicvine_api_key(session) series_id = series.id series_cover_url = series.cover_url series_has_cover = bool(series.cover_path) - # Collect issue cover work list: (issue_id, cover_url, issue_number) + # Preserve exact designations: numeric compatibility values can collide. result = await session.execute( - select(Issue.id, Issue.cover_url, Issue.issue_number).where( + select( + Issue.id, Issue.cover_url, Issue.issue_number, Issue.issue_number_text + ).where( Issue.series_id == event.series_id, Issue.cover_url.isnot(None), Issue.cover_path.is_(None), ) ) - issue_work: list[tuple[int, str, float]] = [ - (row.id, row.cover_url, row.issue_number) for row in result.all() + issue_work: list[tuple[int, str, float, str | None]] = [ + (row.id, row.cover_url, row.issue_number, row.issue_number_text) + for row in result.all() ] except Exception: log.exception("subscriber_cover_download_load_failed") @@ -198,47 +202,55 @@ async def _download_covers_for_series(event: SeriesAdded) -> None: covers_dir = covers_base / str(series_id) - provider = ComicVineProvider(api_key=api_key) - svc = MetadataService(provider=provider, covers_dir=covers_base) - - # Download series cover - if series_cover_url and not series_has_cover: - try: - series_cover_dest = covers_dir / "series.jpg" - await svc.download_cover(series_cover_url, series_cover_dest) - if series_cover_dest.exists(): - async with factory() as session: - series_obj = await session.get(Series, series_id) - if series_obj and not series_obj.cover_path: - series_obj.cover_path = f"/api/v1/series/{series_id}/cover" - await session.commit() - log.info("subscriber_series_cover_downloaded") - except Exception: - log.exception("subscriber_series_cover_failed") - - # Download issue covers downloaded = 0 - for issue_id, cover_url, issue_number in issue_work: - try: - num_str = ( - f"{int(issue_number):03d}" - if issue_number == int(issue_number) - else f"{issue_number:06.1f}" - ) - cover_dest = covers_dir / f"issue_{num_str}.jpg" - await svc.download_cover(cover_url, cover_dest) - if cover_dest.exists(): - async with factory() as session: - issue_obj = await session.get(Issue, issue_id) - if issue_obj and not issue_obj.cover_path: - issue_obj.cover_path = f"/api/v1/issues/{issue_id}/cover" - await session.commit() - downloaded += 1 - except Exception: - log.exception("subscriber_issue_cover_failed", issue_id=issue_id) - - # Small delay to respect CDN rate limits - await asyncio.sleep(0.2) + async with ProviderArtworkClient() as artwork: + if series_cover_url and not series_has_cover: + try: + series_cover_dest = covers_dir / "series.jpg" + async with pending_provider_cover( + artwork, series_cover_url, series_cover_dest + ) as pending: + if pending is not None: + async with factory() as session: + series_obj = await session.get(Series, series_id) + if ( + series_obj + and not series_obj.cover_path + and series_obj.cover_url == series_cover_url + ): + pending.replace(series_cover_dest) + series_obj.cover_path = f"/api/v1/series/{series_id}/cover" + await session.commit() + log.info("subscriber_series_cover_downloaded") + except Exception: + log.exception("subscriber_series_cover_failed") + + for issue_id, cover_url, issue_number, exact in issue_work: + try: + stem = issue_cover_stem(issue_number, exact) + cover_dest = covers_dir / f"{stem}.jpg" + async with pending_provider_cover(artwork, cover_url, cover_dest) as pending: + if pending is not None: + async with factory() as session: + issue_obj = await session.get(Issue, issue_id) + if ( + issue_obj + and not issue_obj.cover_path + and issue_obj.series_id == series_id + and issue_obj.cover_url == cover_url + and issue_cover_stem( + issue_obj.issue_number, issue_obj.issue_number_text + ) + == stem + ): + pending.replace(cover_dest) + issue_obj.cover_path = f"/api/v1/issues/{issue_id}/cover" + await session.commit() + downloaded += 1 + except Exception: + log.exception("subscriber_issue_cover_failed", issue_id=issue_id) + + await asyncio.sleep(0.2) log.info( "subscriber_cover_download_complete", diff --git a/src/pullbox/services/provider_artwork.py b/src/pullbox/services/provider_artwork.py new file mode 100644 index 00000000..fb5f245d --- /dev/null +++ b/src/pullbox/services/provider_artwork.py @@ -0,0 +1,200 @@ +"""Provider-independent downloading of public metadata artwork.""" + +import asyncio +import hashlib +import io +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from urllib.parse import unquote, urlsplit +from uuid import uuid4 + +import httpx +import structlog +from PIL import Image + +from pullbox.core.issue_numbers import normalize_issue_number_text + +logger = structlog.get_logger(__name__) +MAX_ARTWORK_BYTES = 16 * 1024 * 1024 +MAX_ARTWORK_PIXELS = 20_000_000 + + +def _allowed_url(url: str) -> bool: + if len(url) > 4096 or any(char.isspace() or ord(char) < 32 for char in url): + return False + try: + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.port not in {None, 443} + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + return False + path = unquote(parsed.path) + if "\\" in path or any(ord(char) < 32 for char in path) or ".." in path.split("/"): + return False + host = parsed.hostname or "" + if host in {"metron.cloud", "static.metron.cloud"}: + return path.startswith("/media/") + return host in {"comicvine.gamespot.com", "comicvine.com"} or host.endswith( + ".cbsistatic.com" + ) + except ValueError: + return False + + +def _jpeg_payload(data: bytes) -> bytes: + with Image.open(io.BytesIO(data)) as image: + if ( + image.format not in {"JPEG", "PNG", "WEBP"} + or image.width * image.height > MAX_ARTWORK_PIXELS + ): + raise ValueError("Unsupported artwork format or dimensions") + image.verify() + with Image.open(io.BytesIO(data)) as image: + image.load() + if image.format == "JPEG": + return data + output = io.BytesIO() + with image.convert("RGB") as converted: + converted.save(output, format="JPEG", quality=90) + result = output.getvalue() + if len(result) > MAX_ARTWORK_BYTES: + raise ValueError("Artwork exceeds cache size limit") + return result + + +def _stage_cover(destination: Path, data: bytes) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + stage: Path | None = None + try: + with tempfile.NamedTemporaryFile( + dir=destination.parent, + prefix=".cover-", + delete=False, + ) as file: + stage = Path(file.name) + file.write(data) + except BaseException: + if stage is not None: + stage.unlink(missing_ok=True) + raise + return stage + + +async def _publish_cover(destination: Path, data: bytes) -> None: + # A cancelled thread may still finish its write. Join it, then remove its stage. + worker = asyncio.create_task(asyncio.to_thread(_stage_cover, destination, data)) + stage: Path | None = None + try: + stage = await asyncio.shield(worker) + stage.replace(destination) + except asyncio.CancelledError: + while not worker.done(): + try: + await asyncio.shield(worker) + except asyncio.CancelledError: + continue + except Exception: + break + if not worker.cancelled() and worker.exception() is None: + stage = worker.result() + raise + finally: + if stage is not None: + stage.unlink(missing_ok=True) + + +class ProviderArtworkClient: + """Reuse an unauthenticated HTTP client for allowlisted public CDN artwork.""" + + def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None) -> None: + self._client = httpx.AsyncClient( + timeout=10.0, + transport=transport, + follow_redirects=False, + headers={"User-Agent": "Pullbox", "Accept-Encoding": "identity"}, + ) + + async def __aenter__(self) -> "ProviderArtworkClient": + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self._client.aclose() + + async def download_cover(self, url: str, destination: Path) -> bool: + """Publish a verified JPEG atomically, leaving any old cover on failure.""" + if not _allowed_url(url): + logger.warning("provider_artwork_url_rejected") + return False + try: + self._client.cookies.clear() + async with asyncio.timeout(20): + async with self._client.stream("GET", url) as response: + response.raise_for_status() + if response.headers.get("content-type", "").split(";", 1)[ + 0 + ].strip().lower() not in { + "image/jpeg", + "image/png", + "image/webp", + "application/octet-stream", + }: + raise ValueError("Unsupported artwork content type") + length = response.headers.get("content-length") + if length is not None and not 0 <= int(length) <= MAX_ARTWORK_BYTES: + raise ValueError("Artwork exceeds download size limit") + content = bytearray() + async for chunk in response.aiter_bytes(64 * 1024): + if len(content) + len(chunk) > MAX_ARTWORK_BYTES: + raise ValueError("Artwork exceeds download size limit") + content.extend(chunk) + payload = await asyncio.to_thread(_jpeg_payload, bytes(content)) + await _publish_cover(destination, payload) + except ( + httpx.HTTPError, + OSError, + ValueError, + TimeoutError, + Image.DecompressionBombError, + ) as exc: + logger.warning("provider_artwork_download_failed", error_type=type(exc).__name__) + return False + return True + + +@asynccontextmanager +async def pending_provider_cover( + client: ProviderArtworkClient, + url: str, + destination: Path, +) -> AsyncIterator[Path | None]: + """Keep new artwork invisible until the caller revalidates its DB snapshot.""" + pending = destination.with_name(f".{uuid4().hex}.{destination.name}") + try: + yield pending if await client.download_cover(url, pending) else None + finally: + pending.unlink(missing_ok=True) + + +def issue_cover_stem(issue_number: float, issue_number_text: str | None = None) -> str: + """Retain unambiguous legacy names; distinguish suffixes and precise decimals.""" + raw = issue_number_text or str(issue_number) + try: + exact = normalize_issue_number_text(raw) + number = ( + f"{int(issue_number):03d}" + if issue_number == int(issue_number) + else f"{issue_number:06.1f}" + ) + if normalize_issue_number_text(number) == exact: + return f"issue_{number}" + except (ValueError, OverflowError): + exact = raw + # Cache identity only, not a credential; bounded for arbitrary issue labels. + return "issue_exact_" + hashlib.sha256(exact.encode("utf-8")).hexdigest() diff --git a/tests/unit/test_core_subscribers.py b/tests/unit/test_core_subscribers.py index 46817942..24736cef 100644 --- a/tests/unit/test_core_subscribers.py +++ b/tests/unit/test_core_subscribers.py @@ -449,7 +449,13 @@ class FailingMetadataService: def __init__(self, **_kwargs: object) -> None: return None - async def download_cover(self, _url: str, _destination: Path) -> None: + async def __aenter__(self) -> FailingMetadataService: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def download_cover(self, _url: str, _destination: Path) -> bool: raise RuntimeError("cdn failed") monkeypatch.setattr(subscribers.asyncio, "sleep", AsyncNoop()) @@ -458,7 +464,7 @@ async def download_cover(self, _url: str, _destination: Path) -> None: monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", fake_covers_dir) monkeypatch.setattr("pullbox.providers.metadata.comicvine.ComicVineProvider", FakeProvider) monkeypatch.setattr( - "pullbox.services.metadata_service.MetadataService", + "pullbox.services.provider_artwork.ProviderArtworkClient", FailingMetadataService, ) @@ -523,19 +529,29 @@ class FakeMetadataService: def __init__(self, **_kwargs: object) -> None: return None - async def download_cover(self, url: str, destination: Path) -> None: - downloaded_destinations.append(destination.name) + async def __aenter__(self) -> FakeMetadataService: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def download_cover(self, url: str, destination: Path) -> bool: + assert destination.name.startswith(".") + downloaded_destinations.append(destination.name.split(".", 2)[2]) if "001.5" in url: raise RuntimeError("cdn failed") destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b"cover") + return True monkeypatch.setattr(subscribers.asyncio, "sleep", fake_sleep) monkeypatch.setattr(subscribers, "get_session_factory", lambda: factory) monkeypatch.setattr("pullbox.core.comicvine_key.get_comicvine_api_key", fake_api_key) monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", fake_covers_dir) monkeypatch.setattr("pullbox.providers.metadata.comicvine.ComicVineProvider", FakeProvider) - monkeypatch.setattr("pullbox.services.metadata_service.MetadataService", FakeMetadataService) + monkeypatch.setattr( + "pullbox.services.provider_artwork.ProviderArtworkClient", FakeMetadataService + ) await subscribers._download_covers_for_series( SeriesAdded(series_id=series_id, comicvine_id=123) diff --git a/tests/unit/test_provider_artwork.py b/tests/unit/test_provider_artwork.py new file mode 100644 index 00000000..9b92f65a --- /dev/null +++ b/tests/unit/test_provider_artwork.py @@ -0,0 +1,287 @@ +"""Artwork is credential-free, bounded, validated and exact-issue-safe.""" + +import asyncio +import io + +import httpx +import pytest +from PIL import Image + +from pullbox.services.provider_artwork import ProviderArtworkClient, issue_cover_stem + + +def cover_bytes(format="JPEG"): + output = io.BytesIO() + with Image.new("RGB", (2, 3), "red") as image: + image.save(output, format=format) + return output.getvalue() + + +@pytest.mark.parametrize( + "url", + [ + "https://comicvine.gamespot.com/a.jpg", + "https://comicvine.com/a.jpg", + "https://comicvine.gamespot.com/a%20b.jpg", + "https://comicvine.gamespot.com:443/a.jpg", + "https://comicvine.gamespot.com/a", + "https://comicvine.gamespot.com/a.jpeg", + "https://comicvine.gamespot.com/a.png", + "https://static.metron.cloud/media/issue/test.jpg", + "https://metron.cloud/media/series/test.jpg", + "https://images.cbsistatic.com/cover.jpg", + "https://comicvine.gamespot.com/a.webp", + "https://comicvine.gamespot.com/a.jpg#", + "https://comicvine.gamespot.com/a.jpg?", + ], +) +async def test_public_cover_download_without_provider_credentials(tmp_path, url): + payload = cover_bytes() + requests = [] + + def handle(request): + requests.append(request) + assert "authorization" not in request.headers and "cookie" not in request.headers + assert "api_key" not in request.url.params + return httpx.Response(200, content=payload, headers={"content-type": "image/jpeg"}) + + destination = tmp_path / "cache" / "series.jpg" + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + assert await client.download_cover(url, destination) + assert len(requests) == 1 and destination.read_bytes() == payload + assert list(destination.parent.iterdir()) == [destination] + + +@pytest.mark.parametrize( + "url", + [ + "http://comicvine.gamespot.com/a.jpg", + "https://127.0.0.1/cover.jpg", + "https://static.metron.cloud.evil.test/media/a.jpg", + "https://user:pass@static.metron.cloud/media/a.jpg", + "https://static.metron.cloud:8443/media/a.jpg", + "https://static.metron.cloud/api/token/", + "https://static.metron.cloud/media/../api/token/", + "https://static.metron.cloud/media/%2e%2e/api/token/", + "https://comicvine.gamespot.com/a.jpg?api_key=secret", + "https://example.test/a.jpg", + ], +) +async def test_rejects_untrusted_artwork_before_network(tmp_path, url): + def handle(request): + pytest.fail("untrusted URL must not be requested") + + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + assert not await client.download_cover(url, tmp_path / "cover.jpg") + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("failure", ["redirect", "html", "corrupt", "oversize", "timeout"]) +async def test_failed_download_never_replaces_existing_cover(tmp_path, failure): + destination = tmp_path / "cover.jpg" + destination.write_bytes(b"existing") + calls = [] + + def handle(request): + calls.append(request) + if failure == "timeout": + raise httpx.ReadTimeout("provider timed out", request=request) + if failure == "redirect": + return httpx.Response(302, headers={"location": "http://127.0.0.1/private"}) + if failure == "oversize": + return httpx.Response( + 200, + headers={"content-length": str(100 * 1024 * 1024), "content-type": "image/jpeg"}, + content=b"x", + ) + if failure == "html": + return httpx.Response( + 200, content=b"login", headers={"content-type": "text/html"} + ) + return httpx.Response(200, content=b"corrupt", headers={"content-type": "image/jpeg"}) + + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + assert not await client.download_cover( + "https://static.metron.cloud/media/issue/a.jpg", destination + ) + assert len(calls) == 1 + assert destination.read_bytes() == b"existing" and list(tmp_path.iterdir()) == [destination] + + +async def test_cancellation_propagates_and_leaves_no_partial_file(tmp_path): + async def handle(request): + raise asyncio.CancelledError + + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + with pytest.raises(asyncio.CancelledError): + await client.download_cover( + "https://static.metron.cloud/media/a.jpg", tmp_path / "cover.jpg" + ) + assert not list(tmp_path.iterdir()) + + +def test_exact_issue_designations_have_distinct_bounded_cover_names(): + designations = [ + (13, "13"), + (13, "13A"), + (13, "13B"), + (50, "50-X"), + (50, "50X"), + (50, "50-O"), + (1.25, "1.25"), + (1.2, "1.2"), + (1.5, "1.5"), + (13, "13" + "A" * 318), + ] + stems = [issue_cover_stem(number, exact) for number, exact in designations] + assert len(set(stems)) == len(stems) + assert all(len(stem) < 200 and "/" not in stem and "\\" not in stem for stem in stems) + assert issue_cover_stem(1, None) == "issue_001" + assert issue_cover_stem(1.5, None) == "issue_0001.5" + assert issue_cover_stem(13, "013a") == issue_cover_stem(13, "13A") + + +@pytest.mark.parametrize("format", ["PNG", "WEBP"]) +async def test_image_format_is_normalized_to_destination_jpeg(tmp_path, format): + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, + content=cover_bytes(format), + headers={"content-type": f"image/{format.lower()}"}, + ) + ) + destination = tmp_path / "cover.jpg" + async with ProviderArtworkClient(transport=transport) as client: + assert await client.download_cover("https://metron.cloud/media/a.jpg", destination) + with Image.open(destination) as image: + assert image.format == "JPEG" and image.size == (2, 3) + + +async def test_response_cookies_are_never_reused_for_artwork(tmp_path): + def handle(request): + assert "cookie" not in request.headers + return httpx.Response( + 200, + content=cover_bytes(), + headers={ + "content-type": "image/jpeg", + "set-cookie": "session=remote; Path=/", + }, + ) + + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + for number in range(2): + assert await client.download_cover( + "https://metron.cloud/media/a.jpg", tmp_path / f"{number}.jpg" + ) + + +async def test_unbounded_stream_stops_at_byte_budget(tmp_path, monkeypatch): + monkeypatch.setattr("pullbox.services.provider_artwork.MAX_ARTWORK_BYTES", 65536) + read = [] + + class Stream(httpx.AsyncByteStream): + async def __aiter__(self): + for number in range(100): + read.append(number) + yield b"x" * 65536 + + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, + stream=Stream(), + headers={"content-type": "image/jpeg"}, + ) + ) + async with ProviderArtworkClient(transport=transport) as client: + assert not await client.download_cover( + "https://metron.cloud/media/a.jpg", tmp_path / "cover.jpg" + ) + assert read == [0, 1] and not list(tmp_path.iterdir()) + + +async def test_cancel_during_staging_joins_writer_without_publishing(tmp_path, monkeypatch): + import threading + + from pullbox.services import provider_artwork as artwork + + loop = asyncio.get_running_loop() + started = asyncio.Event() + finish = threading.Event() + original = artwork._stage_cover + + def stage(destination, data): + path = original(destination, data) + loop.call_soon_threadsafe(started.set) + assert finish.wait(5) + return path + + monkeypatch.setattr(artwork, "_stage_cover", stage) + destination = tmp_path / "cover.jpg" + destination.write_bytes(b"previous") + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, + content=cover_bytes(), + headers={"content-type": "image/jpeg"}, + ) + ) + async with ProviderArtworkClient(transport=transport) as client: + task = asyncio.create_task( + client.download_cover("https://metron.cloud/media/a.jpg", destination) + ) + try: + await asyncio.wait_for(started.wait(), 5) + task.cancel() + finally: + finish.set() + with pytest.raises(asyncio.CancelledError): + await task + assert destination.read_bytes() == b"previous" + assert list(tmp_path.iterdir()) == [destination] + + +async def test_pixel_budget_rejection_preserves_existing_cover(tmp_path, monkeypatch): + monkeypatch.setattr("pullbox.services.provider_artwork.MAX_ARTWORK_PIXELS", 5) + destination = tmp_path / "cover.jpg" + destination.write_bytes(b"existing") + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, + content=cover_bytes(), + headers={"content-type": "image/jpeg"}, + ) + ) + async with ProviderArtworkClient(transport=transport) as client: + assert not await client.download_cover("https://metron.cloud/media/a.jpg", destination) + assert destination.read_bytes() == b"existing" + assert client._client.is_closed + + +async def test_stage_close_failure_cleans_up_partial_file(tmp_path, monkeypatch): + from contextlib import contextmanager + + from pullbox.services import provider_artwork as artwork + + original = artwork.tempfile.NamedTemporaryFile + + @contextmanager + def failure(**kwargs): + with original(**kwargs) as file: + yield file + raise OSError("flush failed") + + monkeypatch.setattr(artwork.tempfile, "NamedTemporaryFile", failure) + destination = tmp_path / "cover.jpg" + destination.write_bytes(b"existing") + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, + content=cover_bytes(), + headers={"content-type": "image/jpeg"}, + ) + ) + async with ProviderArtworkClient(transport=transport) as client: + assert not await client.download_cover("https://metron.cloud/media/a.jpg", destination) + assert destination.read_bytes() == b"existing" + assert list(tmp_path.iterdir()) == [destination] diff --git a/tests/unit/test_provider_artwork_subscriber.py b/tests/unit/test_provider_artwork_subscriber.py new file mode 100644 index 00000000..b3101f4f --- /dev/null +++ b/tests/unit/test_provider_artwork_subscriber.py @@ -0,0 +1,155 @@ +"""Native source additions use real artwork transport and isolated DB phases.""" + +from pathlib import Path + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from pullbox.api.v1.covers import get_issue_cover +from pullbox.core import subscribers +from pullbox.core.events import SeriesAdded +from pullbox.models import Issue, Series +from pullbox.services.provider_artwork import ProviderArtworkClient +from tests.unit.test_provider_artwork import cover_bytes +from tests.unit.test_subscriber_transaction_boundaries import _SessionTracker, _TrackingFactory + + +async def test_native_artwork_round_trip_keeps_exact_issue_covers_separate( + async_engine, + tmp_path, + monkeypatch, +): + maker = async_sessionmaker(async_engine, expire_on_commit=False) + async with maker.begin() as session: + series = Series( + title="Native", + sort_title="native", + cover_url="https://static.metron.cloud/media/series/1.jpg", + ) + session.add(series) + await session.flush() + series_id = series.id + issues = [ + Issue( + series_id=series_id, + issue_number=number, + issue_number_text=exact, + cover_url=f"https://static.metron.cloud/media/issue/{exact}.jpg", + ) + for number, exact in [(13, "13A"), (13, "13B"), (1.25, "1.25"), (1.2, "1.2")] + ] + session.add_all(issues) + await session.flush() + ids = [issue.id for issue in issues] + + async def forbidden_key(_session): + pytest.fail("Public artwork must not require a ComicVine credential") + + async def covers(_session): + return tmp_path + + async def sleep(_delay): + pass + + calls = [] + + def handle(request): + assert _SessionTracker.active_sessions == 0 + calls.append(request.url.path) + return httpx.Response(200, content=cover_bytes(), headers={"content-type": "image/jpeg"}) + + monkeypatch.setattr("pullbox.core.comicvine_key.get_comicvine_api_key", forbidden_key) + monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", covers) + monkeypatch.setattr(subscribers, "get_session_factory", lambda: _TrackingFactory(maker)) + monkeypatch.setattr(subscribers.asyncio, "sleep", sleep) + monkeypatch.setattr( + "pullbox.services.provider_artwork.ProviderArtworkClient", + lambda: ProviderArtworkClient(transport=httpx.MockTransport(handle)), + ) + await subscribers._download_covers_for_series(SeriesAdded(series_id=series_id)) + assert len(calls) == 5 + async with maker() as session: + saved = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert all(issue.cover_path == f"/api/v1/issues/{issue.id}/cover" for issue in saved) + paths = [ + Path((await get_issue_cover(issue_id, object(), session)).path) for issue_id in ids + ] + assert len(set(paths)) == 4 + assert all(path.read_bytes() == cover_bytes() for path in paths) + + +async def test_exact_issue_never_uses_another_issues_numeric_cover( + async_engine, tmp_path, monkeypatch +): + maker = async_sessionmaker(async_engine, expire_on_commit=False) + + async def covers(_session): + return tmp_path + + monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", covers) + async with maker.begin() as session: + series = Series(title="Exact", sort_title="exact", path=str(tmp_path / "series")) + session.add(series) + await session.flush() + issue = Issue(series_id=series.id, issue_number=13, issue_number_text="13B") + session.add(issue) + await session.flush() + for directory in (Path(series.path), tmp_path / str(series.id)): + directory.mkdir() + (directory / "issue_013.jpg").write_bytes(cover_bytes()) + response = await get_issue_cover(issue.id, object(), session) + assert response.status_code == 404 + + +@pytest.mark.parametrize("change", ["url", "designation", "series", "delete"]) +async def test_stale_download_does_not_publish_artwork(async_engine, tmp_path, monkeypatch, change): + maker = async_sessionmaker(async_engine, expire_on_commit=False) + async with maker.begin() as session: + series = Series(title="Native", sort_title="native") + other = Series(title="Other", sort_title="other") + session.add_all([series, other]) + await session.flush() + series_id, other_id = series.id, other.id + issue = Issue( + series_id=series_id, + issue_number=13, + issue_number_text="13A", + cover_url="https://metron.cloud/media/a.jpg", + ) + session.add(issue) + await session.flush() + issue_id = issue.id + + async def covers(_session): + return tmp_path + + async def sleep(_delay): + pass + + async def handle(request): + async with maker.begin() as session: + issue = await session.get(Issue, issue_id) + if change == "delete": + await session.delete(issue) + elif change == "series": + issue.series_id = other_id + elif change == "designation": + issue.issue_number_text = "13B" + else: + issue.cover_url = "https://metron.cloud/media/new.jpg" + return httpx.Response(200, content=cover_bytes(), headers={"content-type": "image/jpeg"}) + + monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", covers) + monkeypatch.setattr(subscribers, "get_session_factory", lambda: maker) + monkeypatch.setattr(subscribers.asyncio, "sleep", sleep) + monkeypatch.setattr( + "pullbox.services.provider_artwork.ProviderArtworkClient", + lambda: ProviderArtworkClient(transport=httpx.MockTransport(handle)), + ) + await subscribers._download_covers_for_series(SeriesAdded(series_id=series_id)) + assert not list(tmp_path.rglob("*.jpg")) + async with maker() as session: + issue = await session.get(Issue, issue_id) + assert issue is None or issue.cover_path is None diff --git a/tests/unit/test_subscriber_transaction_boundaries.py b/tests/unit/test_subscriber_transaction_boundaries.py index f06f64fa..81d48e29 100644 --- a/tests/unit/test_subscriber_transaction_boundaries.py +++ b/tests/unit/test_subscriber_transaction_boundaries.py @@ -82,10 +82,17 @@ class FakeMetadataService: def __init__(self, **_kwargs: Any) -> None: return None - async def download_cover(self, _url: str, destination: Path) -> None: + async def __aenter__(self) -> FakeMetadataService: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def download_cover(self, _url: str, destination: Path) -> bool: assert _SessionTracker.active_sessions == 0 destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b"cover") + return True _SessionTracker.active_sessions = 0 monkeypatch.setattr("pullbox.core.subscribers.asyncio.sleep", fake_sleep) @@ -95,7 +102,9 @@ async def download_cover(self, _url: str, destination: Path) -> None: monkeypatch.setattr("pullbox.core.comicvine_key.get_comicvine_api_key", fake_api_key) monkeypatch.setattr("pullbox.services.cover_resolver.resolve_covers_dir", fake_covers_dir) monkeypatch.setattr("pullbox.providers.metadata.comicvine.ComicVineProvider", FakeProvider) - monkeypatch.setattr("pullbox.services.metadata_service.MetadataService", FakeMetadataService) + monkeypatch.setattr( + "pullbox.services.provider_artwork.ProviderArtworkClient", FakeMetadataService + ) await _download_covers_for_series(SeriesAdded(series_id=series_id, comicvine_id=12345)) From eca6db05583f6b49b9ae501327561ddbb629bc3c Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 07:37:01 -0700 Subject: [PATCH 27/97] perf(metadata): limit story arc migration reflection Reflect only the three tables owned by the identity migration instead of traversing unrelated foreign keys. Keep the existing query budget and verify the reflection boundary on SQLite and PostgreSQL. --- ...l0m1n2o345_story_arc_identity_lifecycle.py | 2 +- .../test_arc_identity_migration.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py index eeb394ed..a41297cd 100644 --- a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py +++ b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py @@ -56,7 +56,7 @@ def _digest(value: str) -> str: def _tables() -> tuple[sa.Table, sa.Table, sa.Table]: metadata = sa.MetaData() return tuple( - sa.Table(name, metadata, autoload_with=op.get_bind()) + sa.Table(name, metadata, autoload_with=op.get_bind(), resolve_fks=False) for name in ( "story_arcs", _NAME, diff --git a/tests/integration/metadata_identity/test_arc_identity_migration.py b/tests/integration/metadata_identity/test_arc_identity_migration.py index 8ba8a5f4..d030cae9 100644 --- a/tests/integration/metadata_identity/test_arc_identity_migration.py +++ b/tests/integration/metadata_identity/test_arc_identity_migration.py @@ -417,6 +417,26 @@ def track(_conn, _cursor, _statement, parameters, _context, many): assert (await connection.execute(select(old).order_by(old.c.id))).all() == before +async def test_arc_migration_reflects_only_its_three_owned_tables(identity_probe_db): + engine, _, _ = identity_probe_db + async with engine.begin() as connection: + + def reflected_names(conn): + tables = _migration(conn)._tables() + assert {table.name for table in tables} == { + "story_arcs", + _NAME, + "story_arc_identity_events", + } + return {table.name for table in tables[0].metadata.tables.values()} + + assert await connection.run_sync(reflected_names) == { + "story_arcs", + _NAME, + "story_arc_identity_events", + } + + @pytest.mark.parametrize("change", ["state", "history", "revision", "scoped_lifecycle"]) async def test_arc_downgrade_refuses_to_discard_later_decisions(identity_probe_db, change): engine, factory, _ = identity_probe_db From 50caa388b8b95b20a0ded8eb5d927ef42727f7f5 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 07:55:20 -0700 Subject: [PATCH 28/97] fix(metadata): reject encoded artwork before decoding --- src/pullbox/services/provider_artwork.py | 6 +++++ tests/unit/test_provider_artwork.py | 31 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/pullbox/services/provider_artwork.py b/src/pullbox/services/provider_artwork.py index fb5f245d..fa000cb3 100644 --- a/src/pullbox/services/provider_artwork.py +++ b/src/pullbox/services/provider_artwork.py @@ -137,6 +137,12 @@ async def download_cover(self, url: str, destination: Path) -> bool: async with asyncio.timeout(20): async with self._client.stream("GET", url) as response: response.raise_for_status() + # Refuse HTTP decompression before a decoded chunk can exceed our cap. + if ( + response.headers.get("content-encoding", "identity").strip().lower() + != "identity" + ): + raise ValueError("Unsupported artwork content encoding") if response.headers.get("content-type", "").split(";", 1)[ 0 ].strip().lower() not in { diff --git a/tests/unit/test_provider_artwork.py b/tests/unit/test_provider_artwork.py index 9b92f65a..ce599aec 100644 --- a/tests/unit/test_provider_artwork.py +++ b/tests/unit/test_provider_artwork.py @@ -200,6 +200,37 @@ async def __aiter__(self): assert read == [0, 1] and not list(tmp_path.iterdir()) +@pytest.mark.parametrize("encoding", ["gzip", "br", "deflate", "identity, gzip", "unknown"]) +async def test_encoded_artwork_is_rejected_before_reading_response(tmp_path, encoding): + read = [] + closed = [] + + class Stream(httpx.AsyncByteStream): + async def __aiter__(self): + read.append(True) + yield b"unexpected encoded response" + + async def aclose(self): + closed.append(True) + + def handle(request): + assert request.headers["accept-encoding"] == "identity" + return httpx.Response( + 200, + stream=Stream(), + headers={"content-type": "image/jpeg", "content-encoding": encoding}, + ) + + destination = tmp_path / "cover.jpg" + destination.write_bytes(b"existing") + async with ProviderArtworkClient(transport=httpx.MockTransport(handle)) as client: + assert not await client.download_cover("https://metron.cloud/media/a.jpg", destination) + assert read == [], "Reject encoding before HTTP decoding can expand an unbounded chunk" + assert closed == [True] + assert destination.read_bytes() == b"existing" + assert list(tmp_path.iterdir()) == [destination] + + async def test_cancel_during_staging_joins_writer_without_publishing(tmp_path, monkeypatch): import threading From eb41c776c3b4f23fc9a733b72faa5530101c0417 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 07:58:59 -0700 Subject: [PATCH 29/97] feat(metadata): checkpoint source-bound Add Series preview Bind preview and add requests to a source identity and revision, guard stale responses, and add browser coverage. Checkpoint only: dialog focus restoration and root-aware folder preview still have failing tests. Root-aware preview API implementation and multi-provider search UI remain pending. --- src/pullbox/ui/static/css/input.css | 2 + src/pullbox/ui/static/css/tailwind.css | 2 +- .../ui/templates/pages/add_series.html | 174 ++++++++++-- .../partials/search_result_card.html | 1 + tests/api/test_metadata_sources_api.py | 72 +++++ tests/e2e/test_source_series_add.py | 258 ++++++++++++++++++ 6 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/test_source_series_add.py diff --git a/src/pullbox/ui/static/css/input.css b/src/pullbox/ui/static/css/input.css index f44ec675..77b014d8 100644 --- a/src/pullbox/ui/static/css/input.css +++ b/src/pullbox/ui/static/css/input.css @@ -1267,6 +1267,7 @@ color: var(--pb-text-secondary); } .add-series-modal-title { + overflow-wrap: anywhere; font-family: "Syne", sans-serif; font-size: 1rem; font-weight: 800; @@ -1299,6 +1300,7 @@ color: var(--pb-text-secondary); } .add-series-modal-preview { + overflow-wrap: anywhere; padding: 0.625rem 0.75rem; border-radius: 0.625rem; border: 1px solid var(--pb-border-subtle); diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css index b862fb67..79e331a3 100644 --- a/src/pullbox/ui/static/css/tailwind.css +++ b/src/pullbox/ui/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{overflow-wrap:anywhere;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{overflow-wrap:anywhere;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/src/pullbox/ui/templates/pages/add_series.html b/src/pullbox/ui/templates/pages/add_series.html index 6dfce713..89684238 100644 --- a/src/pullbox/ui/templates/pages/add_series.html +++ b/src/pullbox/ui/templates/pages/add_series.html @@ -104,11 +104,11 @@ x-show="showModal" x-cloak class="modal-shell !mt-0" - @keydown.escape.window="showModal = false" + @keydown.escape.window="closeModal()" > @@ -90,11 +99,14 @@

{% elif result_already_added %} {{ comicvine_in_library_badge() }} + {% elif r.identity_needs_review %} + Identity needs review {% else %} - {% if story_arc.comicvine_id %} - {{ provider_update_link("/story-arcs/" ~ story_arc.id ~ "/catalog-refresh", testid="story-arc-action-provider-review") }} + {% if story_arc.catalog_source_label %} + {{ provider_update_link("/story-arcs/" ~ story_arc.id ~ "/catalog-refresh", testid="story-arc-action-provider-review", source_label=story_arc.catalog_source_label) }} {% endif %} {% if story_arc.active %} diff --git a/tests/api/test_source_arc_catalog_api.py b/tests/api/test_source_arc_catalog_api.py index 56f97957..3a622f1d 100644 --- a/tests/api/test_source_arc_catalog_api.py +++ b/tests/api/test_source_arc_catalog_api.py @@ -44,6 +44,7 @@ async def arc_command_setup(authenticated_client, sec_db, monkeypatch, tmp_path) "failure": None, "on_request": None, "searches": [], + "arc_rows": [{"id": 4, "name": "Native arc"}], } async def handle(request): @@ -52,6 +53,19 @@ async def handle(request): await fixture["on_request"]() if fixture["failure"] == request.url.path: return httpx.Response(429, headers={"Retry-After": "60"}) + if request.url.path == "/api/arc/": + page = int(request.url.params["page"]) + rows = fixture["arc_rows"] + return httpx.Response( + 200, + json=envelope( + rows[(page - 1) * 100 : page * 100], + count=len(rows), + next_url=str(request.url.copy_set_param("page", str(page + 1))) + if page * 100 < len(rows) + else None, + ), + ) if request.url.path == "/api/arc/4/": return httpx.Response(200, json={"id": 4, "name": "Native arc"}) if request.url.path == "/api/arc/4/issue_list/": diff --git a/tests/e2e/test_story_arc_catalog_page.py b/tests/e2e/test_story_arc_catalog_page.py index bc6d2301..04cd2e09 100644 --- a/tests/e2e/test_story_arc_catalog_page.py +++ b/tests/e2e/test_story_arc_catalog_page.py @@ -12,6 +12,7 @@ from playwright.sync_api import Page, Route, expect from tests.e2e.accessibility import assert_no_axe_violations +from tests.e2e.conftest import _TEST_COVER_PNG from tests.e2e.story_arc_file_helpers import configure_arc_file_defaults from tests.story_arc_catalog_fixtures import CatalogProvider @@ -19,7 +20,11 @@ @pytest.fixture -def catalog_provider(monkeypatch: pytest.MonkeyPatch) -> CatalogProvider: +def catalog_provider(monkeypatch: pytest.MonkeyPatch, page: Page) -> CatalogProvider: + page.route( + "https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg", + lambda route: route.fulfill(status=200, content_type="image/png", body=_TEST_COVER_PNG), + ) provider = CatalogProvider() monkeypatch.setattr( "pullbox.core.comicvine_key.get_comicvine_api_key", AsyncMock(return_value="test") @@ -27,6 +32,12 @@ def catalog_provider(monkeypatch: pytest.MonkeyPatch) -> CatalogProvider: monkeypatch.setattr( "pullbox.providers.metadata.comicvine.ComicVineProvider", lambda **_: provider ) + monkeypatch.setattr( + "pullbox.services.metadata_sources.get_comicvine_api_key", AsyncMock(return_value="test") + ) + monkeypatch.setattr( + "pullbox.providers.metadata.sources.ComicVineProvider", lambda *_args, **_kw: provider + ) return provider @@ -45,7 +56,7 @@ def hold_search(route: Route) -> None: page.route("**/story-arcs/add**", hold_search) page.goto(f"{seeded_server}/story-arcs/add", wait_until="networkidle") - query = page.get_by_label("Comic Vine arc name") + query = page.get_by_label("Story Arc name") query.fill("Numbering") query.press("Enter") @@ -53,7 +64,7 @@ def hold_search(route: Route) -> None: expect(indicator).to_be_visible() expect(indicator).to_have_attribute("aria-live", "polite") expect(indicator).to_have_attribute("data-comicvine-search-loading-contract", "v1") - expect(indicator.get_by_text("Searching ComicVine", exact=True)).to_be_visible() + expect(indicator.get_by_text("Searching metadata sources", exact=True)).to_be_visible() expect(indicator.get_by_text("Large catalogs can take a moment.", exact=True)).to_be_visible() spinner = indicator.locator("svg").first expect(spinner).to_have_css("width", "20px") @@ -91,7 +102,9 @@ def test_catalog_results_use_add_series_card_layout( "A test event across multiple comic series." ) expect(card).not_to_have_class(re.compile(r"add-series-result-card-static")) - expect(card.locator(".add-series-result-meta")).to_have_text("Fixture Publisher 2 issues") + expect(card.locator(".add-series-result-meta")).to_have_text( + "ComicVine API Fixture Publisher 2 issues" + ) expect(results.get_by_text("2 matching Story Arcs", exact=True)).to_have_count(0) expect(results.locator("nav")).to_have_count(0) unknown = results.locator(".add-series-result-card").nth(1) @@ -127,18 +140,20 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( page.on("pageerror", lambda error: errors.append(str(error))) configure_arc_file_defaults(page, seeded_server, prefix=True) page.goto(f"{seeded_server}/story-arcs/add", wait_until="domcontentloaded") - query = page.get_by_label("Comic Vine arc name") + query = page.get_by_label("Story Arc name") query.fill("Numbering") cover = page.get_by_role("img", name="Numbering Event") expect(cover).to_be_visible() - expect(cover).to_have_attribute("src", "https://example.test/story-arcs/42.jpg") + expect(cover).to_have_attribute( + "src", "https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg" + ) expect(page.get_by_role("link", name="Preview Numbering Event")).to_be_visible() query.press("Tab") page.get_by_role("link", name="Preview Numbering Event").focus() page.keyboard.press("Enter") - page.wait_for_url("**/story-arcs/catalog/42") + page.wait_for_url("**/story-arcs/catalog/comicvine_api/42") expect( - page.get_by_text("Issues are listed in Comic Vine's returned order.", exact=False) + page.get_by_text("Issues are listed in ComicVine API's returned order.", exact=False) ).to_be_visible() form = page.get_by_test_id("story-arc-catalog-add-form") form.get_by_role("button", name="Move Exact Comics #1000000 down", exact=True).press("Enter") @@ -292,13 +307,13 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( # Provider failures and incomplete responses must never look like no changes. catalog_provider.fail = True page.get_by_role("link", name="Check again", exact=True).click() - expect(page.get_by_role("alert")).to_contain_text("couldn't load this arc") + expect(page.get_by_role("alert")).to_contain_text("Could not finish the story arc") expect(page.get_by_text("This story arc is up to date", exact=True)).to_have_count(0) expect(page.get_by_test_id("story-arc-update-results")).to_have_count(0) catalog_provider.fail = False catalog_provider.metadata = replace(catalog_provider.metadata, membership_complete=False) page.get_by_role("link", name="Check again", exact=True).click() - expect(page.get_by_role("alert")).to_contain_text("Incomplete member list") + expect(page.get_by_role("alert")).to_contain_text(re.compile("incomplete", re.I)) expect(page.get_by_label("I reviewed these provider changes")).to_have_count(0) catalog_provider.metadata = replace(catalog_provider.metadata, membership_complete=True) page.get_by_role("navigation", name="Breadcrumb").get_by_role( @@ -309,7 +324,7 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( catalog_provider.metadata, issue_provider_ids=("101", "103") ) page.get_by_role("link", name="Check for updates", exact=True).click() - expect(page.get_by_text("Comic Vine issue ID 102 — preserved")).to_be_visible() + expect(page.get_by_text("ComicVine API issue ID 102 — preserved")).to_be_visible() additions = page.get_by_test_id("story-arc-update-additions") removals = page.get_by_test_id("story-arc-update-removals") page.set_viewport_size({"width": 1440, "height": 1000}) @@ -354,7 +369,7 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( page.wait_for_url(re.compile(r"/story-arcs/\d+\?.*notice=resolved.*$")) expect(page.get_by_role("button", name="Review issue 2 match")).to_have_count(0) page.goto(f"{seeded_server}/story-arcs/add") - page.get_by_label("Comic Vine arc name").fill("Numbering") + page.get_by_label("Story Arc name").fill("Numbering") expect(page.get_by_test_id("story-arc-existing-title-link")).to_have_attribute( "href", arc_url.removeprefix(seeded_server) ) diff --git a/tests/story_arc_catalog_fixtures.py b/tests/story_arc_catalog_fixtures.py index 65e07478..532b11bc 100644 --- a/tests/story_arc_catalog_fixtures.py +++ b/tests/story_arc_catalog_fixtures.py @@ -38,7 +38,7 @@ async def search_story_arcs_page( title="Numbering Event", description="

A test event across multiple comic series.

", publisher="Fixture Publisher", - cover_url="https://example.test/story-arcs/42.jpg", + cover_url="https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg", declared_issue_count=2, ), StoryArcSearchResult(provider_id="43", title="Already Here"), @@ -76,7 +76,9 @@ async def get_story_arc_issues(self, issue_provider_ids: list[str]) -> list[Issu for provider_id in issue_provider_ids ] - async def get_series(self, provider_id: str) -> SeriesMetadata: + async def get_series( + self, provider_id: str, *, strict_response: bool = False + ) -> SeriesMetadata: return SeriesMetadata( provider_id=provider_id, title="Exact Comics", diff --git a/tests/ui/test_source_story_arc_ui.py b/tests/ui/test_source_story_arc_ui.py new file mode 100644 index 00000000..c09ebcba --- /dev/null +++ b/tests/ui/test_source_story_arc_ui.py @@ -0,0 +1,183 @@ +"""Real source registry and catalog commands behind the Story Arc browser flow.""" + +import json +import re +from html import unescape +from urllib.parse import urlencode + +from sqlalchemy import func, select, update + +from pullbox.models import StoryArc +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.story_arc import IssueStoryArc +from tests.api.test_metadata_sources_api import csrf, policy +from tests.api.test_source_arc_catalog_api import arc_command_setup as _arc_command_setup +from tests.unit.test_metron_source import issue_row + +pytest_plugins = ["conftest_security"] +arc_command_setup = _arc_command_setup +PREVIEW = "/story-arcs/catalog/metron_api/4" + + +def seed(html): + match = re.search(r"]*data-preview-data>(.*?)", html, re.S) + assert match, html[:200] + return json.loads(match[1]) + + +def form(fixture, data): + return [ + ("source_revision", str(data["sourceRevision"])), + ("fingerprint", data["fingerprint"]), + ("file_defaults_fingerprint", data["fileDefaultsFingerprint"]), + ("library_root_id", str(fixture["root_id"])), + ("issue_provider_ids", "101"), + ("issue_provider_ids", "100"), + ("reading_orders", "1"), + ("reading_orders", "2"), + ("skipped_issue_provider_ids", "100"), + ] + + +async def post(client, path, values): + return await client.post( + path, + content=urlencode(values), + headers={**csrf(client), "Content-Type": "application/x-www-form-urlencoded"}, + follow_redirects=False, + ) + + +async def test_native_search_add_refresh_uses_existing_review_and_saver( + authenticated_client, arc_command_setup, sec_db +): + client, fixture = authenticated_client, arc_command_setup + search = await client.get("/story-arcs/add?q=Native&source=metron_api") + assert search.status_code == 200 and f'href="{PREVIEW}"' in search.text + assert 'data-testid="story-arc-source-filter"' in search.text + assert "Metron" in search.text and "Searching metadata sources" in search.text + reviewed = await client.get(PREVIEW) + assert reviewed.status_code == 200 + data = seed(reviewed.text) + assert data["ready"] and data["sourceRevision"] == 1 + assert data["sourceLabel"] == "Metron" + assert [r["issue_number"] for r in data["members"]] == ["13a", "50-x"] + assert "data-order-controls" in reviewed.text and 'name="order_reviewed"' not in reviewed.text + created = await post(client, PREVIEW, form(fixture, data)) + assert created.status_code == 303, created.text + location = created.headers["location"] + arc_id = int(location.split("/")[-1].split("?")[0]) + detail = await client.get(location) + assert f'href="/story-arcs/{arc_id}/catalog-refresh"' in detail.text + assert "Checking Metron for updates" in detail.text + search = await client.get("/story-arcs/add?q=Native&source=metron_api") + assert f'href="/story-arcs/{arc_id}"' in search.text + assert f'href="{PREVIEW}"' not in search.text + fixture["issues"] = [issue_row(101, "50-x"), issue_row(102, "50-o")] + refresh_url = f"/story-arcs/{arc_id}/catalog-refresh" + refresh = await client.get(refresh_url) + assert refresh.status_code == 200 and 'name="source_revision" value="1"' in refresh.text + assert "Compare Metron" in refresh.text and "Comic Vine" not in refresh.text + fingerprint = re.search(r'name="fingerprint" value="([a-f0-9]+)"', refresh.text)[1] + revision = re.search(r'name="expected_revision" value="(\d+)"', refresh.text)[1] + updated = await post( + client, + refresh_url, + { + "fingerprint": fingerprint, + "expected_revision": revision, + "source_revision": "1", + "confirm_refresh": "true", + }, + ) + assert updated.status_code == 303 and "catalog-refreshed" in updated.headers["location"] + async with sec_db() as session: + rows = list( + await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.sequence_number)) + ) + assert [r.source_issue_id for r in rows] == ["101", "100", "102"] + assert rows[1].resolution_state.value == "skipped" + assert rows[2].evidence["catalog_review_required"] + assert (await session.get(StoryArc, arc_id)).comicvine_id is None + assert not list(fixture["root"].iterdir()) + + +async def test_search_pages_reuse_candidates_but_refresh_ownership( + authenticated_client, arc_command_setup +): + client, fixture = authenticated_client, arc_command_setup + fixture["arc_rows"] = [{"id": i + 4, "name": f"Event {i}"} for i in range(103)] + first = await client.get("/story-arcs/add?q=Event&source=metron_api") + assert ( + first.status_code == 200 and first.text.count('data-testid="story-arc-result-card"') == 20 + ) + assert "source=metron_api" in unescape(first.text) + assert [r.url.params["page"] for r in fixture["calls"]] == ["1", "2"] + fixture["calls"].clear() + second = await client.get( + "/story-arcs/add?q=Event&source=metron_api&page=2", headers={"HX-Request": "true"} + ) + assert second.status_code == 200 and "]*hx-swap-oob="outerHTML"', second.text) + + +async def test_source_policy_change_rejects_review_without_file_or_graph_writes( + authenticated_client, arc_command_setup, sec_db +): + client, fixture = authenticated_client, arc_command_setup + reviewed = await client.get(PREVIEW) + assert reviewed.status_code == 200 + async with sec_db.begin() as session: + await session.execute(update(MetadataSourceConfig).values(revision=2)) + fixture["calls"].clear() + result = await post(client, PREVIEW, form(fixture, seed(reviewed.text))) + assert result.status_code == 303 and "error=" in result.headers["location"] + assert not fixture["calls"] and not list(fixture["root"].iterdir()) + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 0 + + +async def test_search_keeps_native_results_when_other_source_unavailable( + authenticated_client, arc_command_setup +): + result = await authenticated_client.get("/story-arcs/add?q=Native") + assert result.status_code == 200 and f'href="{PREVIEW}"' in result.text + assert "ComicVine API" in result.text + assert "needs credentials" in result.text + assert "does not support series search" not in result.text + + +async def test_disabled_source_and_invalid_selection_never_execute( + authenticated_client, arc_command_setup +): + client, fixture = authenticated_client, arc_command_setup + saved = await client.put( + "/api/v1/metadata/sources/metron_api", + json=policy(revision=1, enabled=False), + headers=csrf(client), + ) + assert saved.status_code == 200 + result = await client.get(PREVIEW) + assert result.status_code == 200 and not seed(result.text)["ready"] + assert not fixture["calls"] + for path in ("/story-arcs/add?q=Event&source=locg", "/story-arcs/catalog/locg/4"): + assert (await client.get(path)).status_code == 422 + + +async def test_native_browser_commands_require_auth_and_csrf( + authenticated_client, unauthenticated_client, arc_command_setup +): + assert (await unauthenticated_client.get(PREVIEW, follow_redirects=False)).status_code in { + 303, + 401, + } + assert (await authenticated_client.post(PREVIEW, data={})).status_code == 403 + assert not arc_command_setup["calls"] + + +async def test_default_source_form_accepts_empty_source(authenticated_client, arc_command_setup): + response = await authenticated_client.get("/story-arcs/add?q=Native&source=") + assert response.status_code == 200 + assert f'href="{PREVIEW}"' in response.text + assert arc_command_setup["calls"] diff --git a/tests/ui/test_story_arc_catalog_ui.py b/tests/ui/test_story_arc_catalog_ui.py index 2a2846fc..07fc18cc 100644 --- a/tests/ui/test_story_arc_catalog_ui.py +++ b/tests/ui/test_story_arc_catalog_ui.py @@ -40,6 +40,12 @@ def catalog_provider(monkeypatch: pytest.MonkeyPatch) -> CatalogProvider: monkeypatch.setattr( "pullbox.providers.metadata.comicvine.ComicVineProvider", lambda **_: provider ) + monkeypatch.setattr( + "pullbox.services.metadata_sources.get_comicvine_api_key", AsyncMock(return_value="test") + ) + monkeypatch.setattr( + "pullbox.providers.metadata.sources.ComicVineProvider", lambda *_args, **_kw: provider + ) return provider @@ -87,10 +93,10 @@ async def test_registry_links_to_dedicated_comicvine_add_page( response.text, ) assert 'class="h-5 w-5 animate-spin"' in response.text - assert "Searching ComicVine" in response.text + assert "Searching metadata sources" in response.text assert "Large catalogs can take a moment." in response.text assert 'data-testid="story-arc-add-footer-dock"' in response.text - assert "Search Comic Vine" in response.text + assert "Search Story Arcs" in response.text assert 'data-testid="story-arcs-create-form"' not in response.text @@ -162,7 +168,7 @@ async def test_catalog_search_renders_provider_cover_and_missing_cover_fallback( ) assert response.status_code == 200 - assert 'src="https://example.test/story-arcs/42.jpg"' in response.text + assert 'src="https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg"' in response.text assert 'alt="Numbering Event"' in response.text assert 'loading="lazy"' in response.text assert response.text.count('class="add-series-result-cover-empty"') == 1 From de93197f64d0189ebda8f8b3dc327f10ba9ebe57 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 11:36:01 -0700 Subject: [PATCH 39/97] feat(metadata): refresh monitored arcs through configured sources Use verified source identities and story-arc priorities for scheduled updates, with consent and revision rechecks before saving. Keep cancellation, import deferral, and per-arc failure isolation intact. Update legacy monitoring tests to exercise the shared source adapters. --- src/pullbox/services/metadata_discovery.py | 17 +- src/pullbox/tasks/story_arc_metadata_task.py | 299 ++++++++++++------ .../test_source_arc_monitoring.py | 175 ++++++++++ tests/unit/test_story_arc_monitoring.py | 77 +++-- 4 files changed, 452 insertions(+), 116 deletions(-) create mode 100644 tests/integration/metadata_identity/test_source_arc_monitoring.py diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index af5b165c..14a25baa 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -235,8 +235,17 @@ def _ordered_sources( *, domain: MetadataDomain = MetadataDomain.CORE, ) -> list[MetadataSource]: + return self.ordered_sources(sources=query.sources, domain=domain) + + def ordered_sources( + self, + *, + sources: Sequence[MetadataSource] | None = None, + domain: MetadataDomain = MetadataDomain.CORE, + ) -> list[MetadataSource]: + """Use the same configured authority for discovery and exact-ID refresh.""" return sorted( - query.sources if query.sources is not None else self.runtime, + sources if sources is not None else self.runtime, key=lambda source: ( self.runtime[source].policy.domain_priorities.get( domain, self.runtime[source].policy.priority @@ -247,6 +256,12 @@ def _ordered_sources( ), ) + def source_availability( + self, source: MetadataSource, capability: SourceCapability + ) -> SourceStatus | None: + """Inspect configuration and capabilities without constructing a client.""" + return self._unavailable(source, capability=capability) + @staticmethod def _group_pages(pages: Sequence[tuple[SourcePage, SourceOutcome]]) -> SeriesDiscoveryRead: grouped = {} diff --git a/src/pullbox/tasks/story_arc_metadata_task.py b/src/pullbox/tasks/story_arc_metadata_task.py index f08e4af2..a8f0eae9 100644 --- a/src/pullbox/tasks/story_arc_metadata_task.py +++ b/src/pullbox/tasks/story_arc_metadata_task.py @@ -1,24 +1,30 @@ -"""Daily, bounded provider membership discovery for monitored story arcs.""" +"""Daily, bounded source membership discovery for monitored story arcs.""" from __future__ import annotations +import asyncio from datetime import UTC, datetime from typing import TYPE_CHECKING import structlog -from sqlalchemy import func, select +from sqlalchemy import func, or_, select, update from sqlalchemy.exc import SQLAlchemyError -from pullbox.core.comicvine_key import get_comicvine_api_key +from pullbox.config import get_settings +from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource +from pullbox.core.metadata_identity_state import IdentityVerificationState from pullbox.core.scheduler import scheduled_task from pullbox.database import get_session_factory -from pullbox.models.issue import Issue from pullbox.models.library import LibraryRoot -from pullbox.models.series import Series -from pullbox.models.story_arc import IssueStoryArc, StoryArc, StoryArcLifecycle -from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider +from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity, StoryArcLifecycle +from pullbox.schemas.metadata_arc_catalog import ArcCatalogSelection +from pullbox.schemas.metadata_sources import MetadataDomain, SourceCapability from pullbox.services.import_activity import has_active_import_scheduler_protection -from pullbox.services.story_arc_catalog import MAX_CATALOG_PARENTS, StoryArcCatalogService +from pullbox.services.metadata_arc_catalog import StoryArcSourceError, fetch_source_arc_catalog +from pullbox.services.metadata_arc_commands import catalog_writer +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_sources import load_source_runtime +from pullbox.services.story_arc_catalog_types import StoryArcCatalogError from pullbox.services.story_arc_service import StoryArcServiceError if TYPE_CHECKING: @@ -27,123 +33,228 @@ logger = structlog.get_logger(__name__) _PAGE_SIZE = 25 +_NAMESPACES = tuple({source.identity_namespace.value for source in MetadataSource}) def _active_monitored() -> tuple[ColumnElement[bool], ...]: + verified = ( + select(StoryArcExternalIdentity.id) + .where( + StoryArcExternalIdentity.story_arc_id == StoryArc.id, + StoryArcExternalIdentity.namespace == "story_arc", + StoryArcExternalIdentity.source.in_(_NAMESPACES), + StoryArcExternalIdentity.verification_state == IdentityVerificationState.VERIFIED, + ) + .exists() + ) return ( StoryArc.monitored.is_(True), StoryArc.lifecycle == StoryArcLifecycle.ACTIVE, - StoryArc.comicvine_id.is_not(None), + or_(StoryArc.comicvine_id.is_not(None), verified), ) -async def _refresh_arc( - factory: async_sessionmaker[AsyncSession], service: StoryArcCatalogService, arc_id: int -) -> int: - """Snapshot, fetch outside the session, recheck consent, then persist.""" +async def _targets( + session: AsyncSession, arc: StoryArc, registry: MetadataSourceRegistry +) -> tuple[ArcCatalogSelection, ...]: + claims = list( + await session.scalars( + select(StoryArcExternalIdentity).where( + StoryArcExternalIdentity.story_arc_id == arc.id, + StoryArcExternalIdentity.namespace == "story_arc", + StoryArcExternalIdentity.source.in_(_NAMESPACES), + ) + ) + ) + identities = { + claim.source: claim.external_id + for claim in claims + if claim.verification_state is IdentityVerificationState.VERIFIED + } + # A retained canonical claim takes precedence over a legacy compatibility + # key, including a stale/conflicted claim that blocks automatic refresh. + if arc.comicvine_id and not any( + claim.source == IdentityNamespace.COMICVINE for claim in claims + ): + identities[IdentityNamespace.COMICVINE] = str(arc.comicvine_id) + return tuple( + ArcCatalogSelection( + source=source, + external_id=identities[source.identity_namespace], + source_revision=registry.runtime[source].policy.revision, + ) + for source in registry.ordered_sources(domain=MetadataDomain.STORY_ARCS) + if source.identity_namespace in identities + and registry.runtime[source].policy.revision > 0 + and all( + registry.source_availability(source, capability) is None + for capability in ( + SourceCapability.STORY_ARC_DETAILS, + SourceCapability.STORY_ARC_ISSUES, + SourceCapability.SERIES_DETAILS, + ) + ) + ) + + +async def _registry(session: AsyncSession) -> MetadataSourceRegistry: + flag = get_settings().metadata_gcd_api_v2_enabled + return MetadataSourceRegistry( + await load_source_runtime(session, gcd_api_enabled=flag), gcd_api_enabled=flag + ) + + +async def _record_failure( + factory: async_sessionmaker[AsyncSession], + arc_id: int, + revision: int, + targets: tuple[ArcCatalogSelection, ...], + code: str, +) -> None: async with factory() as session: arc = await session.scalar( - select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored()) + select(StoryArc) + .where(StoryArc.id == arc_id, StoryArc.revision == revision, *_active_monitored()) + .with_for_update() ) - if arc is None: - return 0 - provider_id, revision = str(arc.comicvine_id), arc.revision - known_parents = tuple( - str(value) - for value in await session.scalars( - select(Series.comicvine_id) - .join(Issue, Issue.series_id == Series.id) - .join(IssueStoryArc, IssueStoryArc.issue_id == Issue.id) - .where(IssueStoryArc.story_arc_id == arc_id, Series.comicvine_id.is_not(None)) - .distinct() - .limit(MAX_CATALOG_PARENTS) + if arc is None or await _targets(session, arc, await _registry(session)) != targets: + return + await session.execute( + update(StoryArc) + .where(StoryArc.id == arc_id, StoryArc.revision == revision, *_active_monitored()) + .values( + diagnostics={ + **arc.diagnostics, + "provider_refresh_error": { + "code": code, + "checked_at": datetime.now(UTC).isoformat(), + }, + } ) ) - preview = await service.preview(provider_id, known_series_provider_ids=known_parents) - if await has_active_import_scheduler_protection(factory): - return 0 + await session.commit() + + +async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) -> int | None: + """Read exact ownership, release the reader, fetch, then recheck consent.""" async with factory() as session: arc = await session.scalar( select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored()) ) - if arc is None or arc.revision != revision: - return 0 - # Older imports may lack an arc-specific future destination. Only the - # explicitly configured default managed root is a safe fallback. Never - # infer it from an import source or the separate arc-copy directory. - default_roots = list( - await session.scalars( - select(LibraryRoot.id) - .where(LibraryRoot.is_default_managed_destination.is_(True)) - .limit(2) + if arc is None: + return None + revision = arc.revision + registry = await _registry(session) + targets = await _targets(session, arc, registry) + if not targets: + return None + try: + deadline = asyncio.get_running_loop().time() + 120 + failure = None + preview = None + for target in targets: + if await has_active_import_scheduler_protection(factory): + return None + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise StoryArcCatalogError("source_timeout", "Story arc update timed out") + try: + preview = await fetch_source_arc_catalog( + registry, + target.source, + target.external_id, + source_revision=target.source_revision, + timeout=min(120, remaining), + ) + break + except StoryArcSourceError as exc: + failure = exc + if preview is None: + assert failure is not None + raise failure + if await has_active_import_scheduler_protection(factory): + return None + async with factory() as session: + arc = await session.scalar( + select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored()) ) + if arc is None or arc.revision != revision: + return None + if await _targets(session, arc, await _registry(session)) != targets: + return None + # Only the explicit managed default may fill a legacy missing root. + # Never infer it from a source or separate arc-copy folder. + default_roots = list( + await session.scalars( + select(LibraryRoot.id) + .where(LibraryRoot.is_default_managed_destination.is_(True)) + .limit(2) + ) + ) + result = await catalog_writer(preview).refresh( + session, + arc_id, + preview, + expected_revision=revision, + library_root_id=default_roots[0] if len(default_roots) == 1 else None, + ) + await session.commit() + # The wanted sweep rechecks monitoring, skips, dates and duplicates. + return len(result.added_membership_ids) + except (StoryArcServiceError, SQLAlchemyError) as exc: + await _record_failure( + factory, arc_id, revision, targets, getattr(exc, "code", "provider_unavailable") ) - result = await service.refresh( - session, - arc_id, - preview, - expected_revision=revision, - library_root_id=default_roots[0] if len(default_roots) == 1 else None, - ) - await session.commit() - # The shared wanted sweep observes new members after this commit and - # rechecks monitoring, explicit skips, dates, and duplicate downloads. - return len(result.added_membership_ids) + raise async def sync_story_arc_metadata() -> None: - """Refresh each eligible arc once, isolating provider failures per arc.""" + """Refresh each eligible arc once, isolating source failures per arc.""" factory = get_session_factory() if await has_active_import_scheduler_protection(factory): return async with factory() as session: - api_key = await get_comicvine_api_key(session) ceiling = await session.scalar(select(func.max(StoryArc.id)).where(*_active_monitored())) - if not api_key or ceiling is None: + if ceiling is None: return - provider = ComicVineProvider(api_key=api_key) - service = StoryArcCatalogService(provider) - cursor = refreshed = added = failed = 0 - try: - while cursor < ceiling: - async with factory() as session: - ids = list( - await session.scalars( - select(StoryArc.id) - .where(StoryArc.id > cursor, StoryArc.id <= ceiling, *_active_monitored()) - .order_by(StoryArc.id) - .limit(_PAGE_SIZE) - ) + cursor = refreshed = added = failed = skipped = 0 + while cursor < ceiling: + async with factory() as session: + ids = list( + await session.scalars( + select(StoryArc.id) + .where(StoryArc.id > cursor, StoryArc.id <= ceiling, *_active_monitored()) + .order_by(StoryArc.id) + .limit(_PAGE_SIZE) ) - if not ids: - break - for arc_id in ids: - if await has_active_import_scheduler_protection(factory): - return - cursor = arc_id - try: - added += await _refresh_arc(factory, service, arc_id) + ) + if not ids: + break + for arc_id in ids: + if await has_active_import_scheduler_protection(factory): + return + cursor = arc_id + try: + result = await _refresh_arc(factory, arc_id) + if result is None: + skipped += 1 + else: + added += result refreshed += 1 - except (ComicVineError, StoryArcServiceError, SQLAlchemyError) as exc: - failed += 1 - code = getattr(exc, "code", "provider_unavailable") - logger.warning( - "story_arc_metadata_refresh_failed", story_arc_id=arc_id, category=code - ) - async with factory() as session: - arc = await session.get(StoryArc, arc_id) - if arc is not None: - arc.diagnostics = { - **arc.diagnostics, - "provider_refresh_error": { - "code": str(code), - "checked_at": datetime.now(UTC).isoformat(), - }, - } - await session.commit() - finally: - await provider.close() - logger.info("story_arc_metadata_refresh_done", refreshed=refreshed, added=added, failed=failed) + except (StoryArcServiceError, SQLAlchemyError) as exc: + failed += 1 + logger.warning( + "story_arc_metadata_refresh_failed", + story_arc_id=arc_id, + category=getattr(exc, "code", "provider_unavailable"), + ) + logger.info( + "story_arc_metadata_refresh_done", + refreshed=refreshed, + added=added, + failed=failed, + skipped=skipped, + ) @scheduled_task( diff --git a/tests/integration/metadata_identity/test_source_arc_monitoring.py b/tests/integration/metadata_identity/test_source_arc_monitoring.py new file mode 100644 index 00000000..cc6992cc --- /dev/null +++ b/tests/integration/metadata_identity/test_source_arc_monitoring.py @@ -0,0 +1,175 @@ +"""Scheduled source-backed arcs retain the same ownership and review boundaries.""" + +import asyncio + +import pytest +from sqlalchemy import select, update + +from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.story_arc import IssueStoryArc, StoryArcLifecycle +from pullbox.providers.metadata import sources +from pullbox.schemas.metadata_sources import SourceCapability, SourceStatus +from pullbox.services.metadata_arc_commands import source_arc_add_transaction +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.tasks import story_arc_metadata_task as task +from tests.integration.metadata_identity.test_source_arc_commands import prepared +from tests.unit.test_metadata_discovery import registration +from tests.unit.test_source_arc_catalog_fetch import CatalogAdapter + + +async def setup(factory, tmp_path, monkeypatch): + preview, decision = await prepared(factory, tmp_path) + decision.monitored = True + decision.skipped_issue_ids = ["1000"] + async with ( + factory() as session, + source_arc_add_transaction(session, preview, decision) as result, + ): + arc_id = result.arc.id + adapter = CatalogAdapter(2) + monkeypatch.setattr(task, "get_session_factory", lambda: factory) + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: {adapter.source: registration(adapter, capabilities=list(SourceCapability))}, + ) + return arc_id, adapter + + +async def test_scheduled_metron_arc_needs_no_comicvine_and_retains_skips( + identity_probe_db, tmp_path, monkeypatch +): + engine, factory, _ = identity_probe_db + arc_id, adapter = await setup(factory, tmp_path, monkeypatch) + original_call = adapter._call + + async def outside_transaction(*args): + assert engine.pool.checkedout() == 0 + await original_call(*args) + + adapter._call = outside_transaction + await task.sync_story_arc_metadata() + await task.sync_story_arc_metadata() + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.comicvine_id is None + rows = list( + await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.sequence_number)) + ) + assert [row.source_issue_id for row in rows] == ["1000", "1001"] + assert rows[0].resolution_state.value == "skipped" + assert not any(row.sync_eligible for row in rows) + assert rows[1].evidence["catalog_review_required"] is True + assert not any(await session.scalars(select(Series.monitored))) + assert set(await session.scalars(select(Issue.issue_number_text))) == {"50-X", "2"} + assert arc.diagnostics["provider_refresh_error"] is None + assert len(adapter.calls) == 8 and adapter.closed == 8 + assert not list((tmp_path / "comics").iterdir()) + + +@pytest.mark.parametrize("change", ["paused", "archived", "disabled", "stale_identity"]) +async def test_unavailable_arc_or_source_performs_no_reads( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + _, adapter = await setup(factory, tmp_path, monkeypatch) + async with factory.begin() as session: + if change == "paused": + await session.execute(update(StoryArc).values(monitored=False)) + elif change == "archived": + await session.execute(update(StoryArc).values(lifecycle=StoryArcLifecycle.ARCHIVED)) + elif change == "disabled": + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + else: + await session.execute( + update(StoryArcExternalIdentity).values(verification_state="stale") + ) + await task.sync_story_arc_metadata() + assert adapter.calls == [] + async with factory() as session: + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +@pytest.mark.parametrize("change", ["paused", "revision", "source", "identity", "import"]) +async def test_network_race_never_saves_stale_membership( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + _, adapter = await setup(factory, tmp_path, monkeypatch) + original_call = adapter._call + changed = False + + async def mutate(*args): + nonlocal changed + await original_call(*args) + if changed: + return + changed = True + async with factory.begin() as session: + if change == "source": + await session.execute(update(MetadataSourceConfig).values(revision=2)) + elif change == "identity": + await session.execute( + update(StoryArcExternalIdentity).values(verification_state="stale") + ) + elif change == "import": + from pullbox.models.import_job import ImportJob, ImportJobStatus, ImportSourceType + + session.add( + ImportJob( + source_type=ImportSourceType.FOLDER, + source_path="/unused", + status=ImportJobStatus.SCANNING, + ) + ) + else: + await session.execute( + update(StoryArc).values( + **( + {"monitored": False} + if change == "paused" + else {"revision": StoryArc.revision + 1} + ) + ) + ) + + adapter._call = mutate + await task.sync_story_arc_metadata() + assert changed, "The scheduled task must include a native-only Story Arc" + async with factory() as session: + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +async def test_cancellation_closes_source_without_mutation( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + _, adapter = await setup(factory, tmp_path, monkeypatch) + adapter.wait = asyncio.Event() + running = asyncio.create_task(task.sync_story_arc_metadata()) + try: + await asyncio.wait_for(adapter.started.wait(), timeout=2) + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + assert adapter.closed == 1 + async with factory() as session: + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +async def test_provider_failure_is_recorded_without_mutation( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, adapter = await setup(factory, tmp_path, monkeypatch) + adapter.error = MetadataSourceError(SourceStatus.RATE_LIMITED, 60) + await task.sync_story_arc_metadata() + assert adapter.closed == 1 + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.diagnostics["provider_refresh_error"]["code"] == "source_unavailable" + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 diff --git a/tests/unit/test_story_arc_monitoring.py b/tests/unit/test_story_arc_monitoring.py index 597611b8..69887541 100644 --- a/tests/unit/test_story_arc_monitoring.py +++ b/tests/unit/test_story_arc_monitoring.py @@ -1,16 +1,40 @@ """Monitoring and reading-order approval are independent of issue identity.""" -from unittest.mock import AsyncMock +from dataclasses import replace +from unittest.mock import AsyncMock, Mock from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker +from pullbox.core.metadata_identity import MetadataSource from pullbox.models.issue import Issue +from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import Series from pullbox.models.story_arc import StoryArcResolutionState +from pullbox.providers.metadata import sources +from pullbox.services.metadata_discovery import SourceRegistration from pullbox.services.story_arc_service import StoryArcService +async def _configure_source(session, monkeypatch, provider): + from tests.unit.test_story_arc_catalog import _series + + provider.close = AsyncMock() + provider.get_series = AsyncMock(side_effect=lambda value, **_: _series(value)) + session.add(MetadataSourceConfig(source="comicvine_api", enabled=True, priority=1, revision=1)) + await session.commit() + capabilities = sources.comicvine_sources()[MetadataSource.COMICVINE_API].capabilities + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: { + MetadataSource.COMICVINE_API: SourceRegistration( + capabilities, lambda _: sources.ComicVineApiSource(provider) + ) + }, + ) + + async def test_order_review_survives_sync_toggle_and_skip_until_explicit_confirmation(db_session): service = StoryArcService() issue = Issue(series=Series(title="Parent", sort_title="parent"), issue_number=1) @@ -36,8 +60,8 @@ async def test_monitoring_task_defers_during_import(monkeypatch): monkeypatch.setattr( task, "has_active_import_scheduler_protection", AsyncMock(return_value=True) ) - provider = AsyncMock() - monkeypatch.setattr(task, "ComicVineProvider", provider) + provider = Mock() + monkeypatch.setattr(task, "MetadataSourceRegistry", provider) await task.sync_story_arc_metadata() provider.assert_not_called() @@ -67,15 +91,13 @@ async def test_scheduled_discovery_is_idempotent_and_keeps_paused_arcs_and_paren db_session.add_all([paused, archived]) await db_session.commit() provider = _provider([_issue(), _issue("12", "22", "1000000")]) - provider.close = AsyncMock() + await _configure_source(db_session, monkeypatch, provider) factory = async_sessionmaker(db_session.bind, expire_on_commit=False) monkeypatch.setattr(task, "get_session_factory", lambda: factory) - monkeypatch.setattr(task, "get_comicvine_api_key", AsyncMock(return_value="test-key")) - monkeypatch.setattr(task, "ComicVineProvider", lambda **_: provider) await task.sync_story_arc_metadata() await task.sync_story_arc_metadata() - assert [call.args[0] for call in provider.get_story_arc.await_args_list] == ["31", "31"] - assert provider.close.await_count == 2 + assert [call.args[0] for call in provider.get_story_arc.await_args_list] == ["31"] * 4 + assert provider.close.await_count == 8 async with factory() as session: members = list( await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.sequence_number)) @@ -90,7 +112,9 @@ async def test_scheduled_discovery_is_idempotent_and_keeps_paused_arcs_and_paren assert (await session.get(StoryArc, arc.id)).diagnostics["provider_refresh_error"] is None -async def test_pause_during_network_fetch_prevents_membership_write(db_session, tmp_path): +async def test_pause_during_network_fetch_prevents_membership_write( + db_session, tmp_path, monkeypatch +): from pullbox.models.story_arc import IssueStoryArc, StoryArc from pullbox.services.story_arc_catalog import StoryArcCatalogService from pullbox.tasks import story_arc_metadata_task as task @@ -108,6 +132,7 @@ async def test_pause_during_network_fetch_prevents_membership_write(db_session, await db_session.commit() factory = async_sessionmaker(db_session.bind, expire_on_commit=False) provider = _provider() + await _configure_source(db_session, monkeypatch, provider) metadata = provider.get_story_arc.return_value async def pause_before_returning(_): @@ -120,32 +145,42 @@ async def pause_before_returning(_): return metadata provider.get_story_arc.side_effect = pause_before_returning - assert await task._refresh_arc(factory, StoryArcCatalogService(provider), arc.id) == 0 + assert await task._refresh_arc(factory, arc.id) is None + provider.get_story_arc.assert_awaited() async with factory() as session: assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 async def test_failed_arc_refresh_does_not_stop_later_arcs(db_session, tmp_path, monkeypatch): - from pullbox.models.story_arc import StoryArc - from pullbox.services.story_arc_catalog import StoryArcCatalogError + from pullbox.models.story_arc import IssueStoryArc, StoryArc + from pullbox.providers.metadata.comicvine import ComicVineError from pullbox.tasks import story_arc_metadata_task as task - from tests.unit.test_story_arc_catalog import _provider + from tests.unit.test_story_arc_catalog import _provider, _root + root = await _root(db_session, tmp_path) + root.is_default_managed_destination = True db_session.add_all( [StoryArc(name=f"Arc {n}", monitored=True, comicvine_id=n) for n in (31, 32)] ) await db_session.commit() factory = async_sessionmaker(db_session.bind, expire_on_commit=False) provider = _provider() - provider.close = AsyncMock() - refresh = AsyncMock(side_effect=[StoryArcCatalogError("incomplete_hydration", "incomplete"), 1]) + metadata = provider.get_story_arc.return_value + + async def get_arc(external_id): + if external_id == "31": + raise ComicVineError(503, "unavailable") + return replace(metadata, provider_id=external_id) + + provider.get_story_arc.side_effect = get_arc + await _configure_source(db_session, monkeypatch, provider) monkeypatch.setattr(task, "get_session_factory", lambda: factory) - monkeypatch.setattr(task, "get_comicvine_api_key", AsyncMock(return_value="test-key")) - monkeypatch.setattr(task, "ComicVineProvider", lambda **_: provider) - monkeypatch.setattr(task, "_refresh_arc", refresh) await task.sync_story_arc_metadata() - assert refresh.await_count == 2 - provider.close.assert_awaited_once() + assert [call.args[0] for call in provider.get_story_arc.await_args_list] == ["31", "32", "32"] + assert provider.close.await_count == 5 async with factory() as session: first = await session.scalar(select(StoryArc).where(StoryArc.comicvine_id == 31)) - assert first.diagnostics["provider_refresh_error"]["code"] == "incomplete_hydration" + second = await session.scalar(select(StoryArc).where(StoryArc.comicvine_id == 32)) + assert first.diagnostics["provider_refresh_error"]["code"] == "source_unavailable" + assert second.diagnostics["provider_refresh_error"] is None + assert list(await session.scalars(select(IssueStoryArc.story_arc_id))) == [second.id] * 2 From 3ac0411dd2ce46df8dd0757aa40907a8aab2f1f4 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 11:56:38 -0700 Subject: [PATCH 40/97] feat(metadata): cache and revalidate source responses --- docs/development/ARCHITECTURE_OVERVIEW.md | 23 +- src/pullbox/api/v1/metadata_sources.py | 21 +- src/pullbox/api/v1/series.py | 8 +- src/pullbox/services/metadata_arc_commands.py | 8 +- src/pullbox/services/metadata_discovery.py | 5 + src/pullbox/services/metadata_read_cache.py | 252 +++++++++++++ src/pullbox/services/metadata_source_reads.py | 101 +++++- src/pullbox/tasks/story_arc_metadata_task.py | 6 +- tests/api/test_source_arc_catalog_api.py | 28 +- .../test_source_read_cache.py | 338 ++++++++++++++++++ 10 files changed, 760 insertions(+), 30 deletions(-) create mode 100644 src/pullbox/services/metadata_read_cache.py create mode 100644 tests/integration/metadata_identity/test_source_read_cache.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 65faffd5..6b18ed59 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -333,10 +333,25 @@ and rechecks source configuration, uses shared identity attachment and caller-ow transactions, and preserves existing exact owners rather than treating Add as Refresh. Native issues require exact parent evidence; foreign issue crosswalks remain observations until independently verified. Unsupported designations and -conflicting ownership fail without partial library rows. This internal workflow -is not yet connected to the Add API/UI or filesystem/event side effects. - -The existing import/cache and Add Series/Story Arc UI consumers are not yet +conflicting ownership fail without partial library rows. The Add Series API/UI +uses this command with caller-owned transactions, managed-folder rollback and +post-commit events. Source-aware Story Arc search, complete command snapshots, +Add/refresh UI and scheduled membership refresh use the same registry and +existing catalog ownership, placement and reading-order services. + +Normalized remote detail and issue-page reads reuse `metadata_provider_cache` +through `services/metadata_read_cache.py`. Keys bind source, policy revision, +operation, external ID and page; credentials are never cache key material. +Responses are fresh for five minutes (not-found for 15 seconds), with validators +retained for one day. Add and refresh commands always revalidate. A not-modified +response requires an exact saved response; outages never become cached success. +The cache is limited to 128 entries of at most 256 KiB each, separate from legacy +ComicVine import entries. It owns short database sessions outside provider I/O, +coalesces at most eight concurrent reads per database, and drains cancelled work. +Availability checks precede cache reads; local catalog reads stay generation-bound +in their existing reader rather than entering this remote-response cache. + +The existing import/cache and ordinary series-refresh consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and GCD API v2 is additionally disabled by the release feature flag. Capability diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py index cd911b2f..36d476a4 100644 --- a/src/pullbox/api/v1/metadata_sources.py +++ b/src/pullbox/api/v1/metadata_sources.py @@ -32,6 +32,7 @@ ) from pullbox.services.metadata_arc_preview import preview_source_arc from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies +from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_series_preview import preview_series_folder, preview_source_series from pullbox.services.metadata_sources import ( SourceConfigurationConflictError, @@ -87,7 +88,11 @@ async def preview_story_arc( ) await session.rollback() result = await preview_source_arc( - MetadataSourceRegistry(runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled), + MetadataSourceRegistry( + runtime, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + read_cache=source_read_cache(session), + ), body.source, body.external_id, ) @@ -111,7 +116,9 @@ async def story_arc_issues( raise HTTPException(409, "Metadata source settings changed. Preview the story arc again.") await session.rollback() result = await MetadataSourceRegistry( - runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + runtime, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + read_cache=source_read_cache(session), ).story_arc_issues(body.source, body.external_id, page=body.page) await _require_source_revision(session, body.source, body.source_revision, subject="story arc") return result @@ -128,7 +135,11 @@ async def preview_series( ) await session.rollback() result = await preview_source_series( - MetadataSourceRegistry(runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled), + MetadataSourceRegistry( + runtime, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + read_cache=source_read_cache(session), + ), body.source, body.external_id, ) @@ -161,7 +172,9 @@ async def series_issues( raise HTTPException(409, "Metadata source settings changed. Preview the series again.") await session.rollback() result = await MetadataSourceRegistry( - runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + runtime, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + read_cache=source_read_cache(session), ).issues(body.source, body.external_id, page=body.page) await _require_source_revision(session, body.source, body.source_revision) return result diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py index 1af0cd0f..293eb7e6 100644 --- a/src/pullbox/api/v1/series.py +++ b/src/pullbox/api/v1/series.py @@ -33,6 +33,7 @@ ) from pullbox.services.cover_url_service import build_series_cover_url from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_series_add import source_series_add_transaction from pullbox.services.metadata_series_adoption import ( SeriesAdoptionError, @@ -396,7 +397,12 @@ async def add_series( await session.rollback() try: bundle = await fetch_source_series_bundle( - MetadataSourceRegistry(runtime, gcd_api_enabled=gcd_enabled), + MetadataSourceRegistry( + runtime, + gcd_api_enabled=gcd_enabled, + read_cache=source_read_cache(session), + revalidate_reads=True, + ), body.source, body.external_id, source_revision=body.source_revision, diff --git a/src/pullbox/services/metadata_arc_commands.py b/src/pullbox/services/metadata_arc_commands.py index 048d3916..0231fcbe 100644 --- a/src/pullbox/services/metadata_arc_commands.py +++ b/src/pullbox/services/metadata_arc_commands.py @@ -18,6 +18,7 @@ from pullbox.schemas.metadata_sources import StoryArcPreviewQuery from pullbox.services.metadata_arc_catalog import fetch_source_arc_catalog from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_sources import load_source_runtime from pullbox.services.story_arc_catalog import StoryArcCatalogService from pullbox.services.story_arc_catalog_identity import require_catalog_source_revision @@ -58,7 +59,12 @@ async def fetch_current_arc_catalog( runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled) await session.rollback() return await fetch_source_arc_catalog( - MetadataSourceRegistry(runtime, gcd_api_enabled=gcd_api_enabled), + MetadataSourceRegistry( + runtime, + gcd_api_enabled=gcd_api_enabled, + read_cache=source_read_cache(session), + revalidate_reads=True, + ), selection.source, selection.external_id, source_revision=selection.source_revision, diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index 14a25baa..f84931a4 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence + from pullbox.services.metadata_read_cache import MetadataReadCache from pullbox.services.metadata_sources import SourceRuntime logger = structlog.get_logger(__name__) @@ -111,6 +112,8 @@ def __init__( per_source_timeout: float = 8, total_timeout: float = 15, concurrency: int = 3, + read_cache: MetadataReadCache | None = None, + revalidate_reads: bool = False, ) -> None: if factories is None: from pullbox.providers.metadata.sources import metadata_sources @@ -127,6 +130,8 @@ def __init__( self.total_timeout = total_timeout self.concurrency = concurrency self.read_slots = asyncio.Semaphore(concurrency) + self.read_cache = read_cache + self.revalidate_reads = revalidate_reads def _unavailable( self, diff --git a/src/pullbox/services/metadata_read_cache.py b/src/pullbox/services/metadata_read_cache.py new file mode 100644 index 00000000..6f989e3a --- /dev/null +++ b/src/pullbox/services/metadata_read_cache.py @@ -0,0 +1,252 @@ +"""Persistent normalized source reads with source-bound conditional validators.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import weakref +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from pullbox.models.provider_cache import MetadataProviderCacheEntry as Entry +from pullbox.schemas.metadata_sources import MetadataFetch, SourceCapability, SourceStatus + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from pullbox.core.metadata_identity import MetadataSource + +logger = structlog.get_logger(__name__) +_KIND = "source-read-v1" +_MAX_BYTES = 256 * 1024 +_MAX_ENTRIES = 128 + + +@dataclass +class _Flight: + task: asyncio.Task[bytes] + waiters: int = 0 + + +@dataclass +class _Coordinator: + pending: dict[tuple[str, bool], _Flight] = field(default_factory=dict) + + +# Coordinators do not retain engines; active fetches own their sessions and are +# drained when their final waiter leaves. Separate application DBs never share. +_coordinators: weakref.WeakKeyDictionary[AsyncEngine, _Coordinator] = weakref.WeakKeyDictionary() + + +def source_read_cache(session: AsyncSession) -> MetadataReadCache | None: + """Use the caller's database without borrowing its transaction.""" + if not isinstance(session.bind, AsyncEngine): + return None + return MetadataReadCache( + async_sessionmaker(session.bind, class_=type(session), expire_on_commit=False) + ) + + +class MetadataReadCache: + def __init__( + self, + factory: async_sessionmaker[AsyncSession], + *, + now: Callable[[], datetime] | None = None, + ) -> None: + self.factory = factory + self.now = now or (lambda: datetime.now(UTC)) + engine = factory.kw.get("bind") + if not isinstance(engine, AsyncEngine): + raise ValueError("Source cache requires its own engine-bound sessions") + self.coordinator = _coordinators.setdefault(engine, _Coordinator()) + + async def _load( + self, source: MetadataSource, key: str + ) -> tuple[datetime, dict[str, object]] | None: + try: + async with asyncio.timeout(2), self.factory() as session: + row = await session.scalar( + select(Entry).where( + Entry.provider_name == source.value, + Entry.cache_kind == _KIND, + Entry.cache_key == key, + Entry.expires_at > self.now(), + ) + ) + return (row.fetched_at, row.payload) if row is not None else None + except (SQLAlchemyError, TimeoutError): + logger.warning("metadata_read_cache_unavailable", source=source.value) + return None + + async def _store( + self, + source: MetadataSource, + key: str, + request: dict[str, object], + payload: bytes, + fetched_at: datetime, + ) -> None: + if len(payload) > _MAX_BYTES: + return + try: + async with asyncio.timeout(2), self.factory.begin() as session: + insert = ( + pg_insert if session.get_bind().dialect.name == "postgresql" else sqlite_insert + ) + statement = insert(Entry).values( + provider_name=source.value, + cache_kind=_KIND, + cache_key=key, + request=request, + payload=json.loads(payload), + fetched_at=fetched_at, + expires_at=fetched_at + timedelta(days=1), + ) + await session.execute( + statement.on_conflict_do_update( + index_elements=[Entry.provider_name, Entry.cache_kind, Entry.cache_key], + set_={ + "payload": statement.excluded.payload, + "fetched_at": statement.excluded.fetched_at, + "expires_at": statement.excluded.expires_at, + }, + where=Entry.fetched_at <= statement.excluded.fetched_at, + ) + ) + oldest = ( + select(Entry.id) + .where( + Entry.cache_kind == _KIND, + Entry.provider_name.in_(["comicvine_api", "metron_api", "gcd_api_v2"]), + ) + .order_by(Entry.fetched_at.desc(), Entry.id.desc()) + .offset(_MAX_ENTRIES) + ) + await session.execute(delete(Entry).where(Entry.id.in_(oldest))) + except (SQLAlchemyError, TimeoutError): + logger.warning("metadata_read_cache_store_failed", source=source.value) + + async def _fetch[T]( + self, + source: MetadataSource, + key: str, + request: dict[str, object], + model: type[MetadataFetch[T]], + loader: Callable[[str | None], Awaitable[MetadataFetch[T]]], + validate: Callable[[T], None], + *, + conditional: bool, + revalidate: bool, + ) -> bytes: + stored = await self._load(source, key) + cached = None + if stored is not None: + try: + cached = model.model_validate(stored[1]) + if cached.status is SourceStatus.OK and cached.data is not None: + validate(cached.data) + elif cached.status is not SourceStatus.NOT_FOUND or cached.data is not None: + raise ValueError("Invalid cached response") + validator = cached.validator + if validator is not None and ( + not 1 <= len(validator) <= 128 + or any(ord(char) < 32 or ord(char) > 126 for char in validator) + ): + raise ValueError("Invalid cached validator") + except (ValueError, TypeError, AttributeError): + cached = None + if cached is not None and stored is not None and not revalidate: + ttl = timedelta(seconds=300 if cached.status is SourceStatus.OK else 15) + if stored[0] <= self.now() < stored[0] + ttl: + return cached.model_dump_json().encode() + validator = ( + cached.validator + if cached is not None and cached.data is not None and conditional + else None + ) + started_at = self.now() + result = await loader(validator) + if result.status is SourceStatus.NOT_MODIFIED: + if cached is None or cached.data is None or validator is None: + return model(status=SourceStatus.INCOMPATIBLE_RESPONSE).model_dump_json().encode() + result = cached + if result.status is SourceStatus.OK: + if result.data is None: + return model(status=SourceStatus.INCOMPATIBLE_RESPONSE).model_dump_json().encode() + validate(result.data) + task = asyncio.current_task() + if task is not None and task.cancelling(): + raise asyncio.CancelledError + payload = result.model_dump_json().encode() + if result.status in {SourceStatus.OK, SourceStatus.NOT_FOUND}: + await self._store(source, key, request, payload, started_at) + return payload + + async def get[T]( + self, + source: MetadataSource, + revision: int, + capability: SourceCapability, + identifier: str, + page: int, + model: type[MetadataFetch[T]], + loader: Callable[[str | None], Awaitable[MetadataFetch[T]]], + validate: Callable[[T], None], + *, + conditional: bool, + revalidate: bool = False, + ) -> MetadataFetch[T]: + request: dict[str, object] = { + "source": source.value, + "revision": revision, + "operation": capability.value, + "external_id": identifier, + "page": page, + } + # A public cache identity, not an authentication or password digest. + key = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest() + flight_key = (key, revalidate) + flight = self.coordinator.pending.get(flight_key) + if flight is not None and flight.task.cancelling(): + return model(status=SourceStatus.UNAVAILABLE, retry_after_seconds=1) + if flight is None: + if len(self.coordinator.pending) >= 8: + return model(status=SourceStatus.UNAVAILABLE, retry_after_seconds=1) + flight = _Flight( + asyncio.create_task( + self._fetch( + source, + key, + request, + model, + loader, + validate, + conditional=conditional, + revalidate=revalidate, + ) + ) + ) + self.coordinator.pending[flight_key] = flight + flight.waiters += 1 + try: + return model.model_validate_json(await asyncio.shield(flight.task)) + finally: + flight.waiters -= 1 + if not flight.waiters: + if not flight.task.done(): + flight.task.cancel() + try: + await asyncio.gather(flight.task, return_exceptions=True) + finally: + if self.coordinator.pending.get(flight_key) is flight: + self.coordinator.pending.pop(flight_key) diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py index 2e777c93..3dd2f655 100644 --- a/src/pullbox/services/metadata_source_reads.py +++ b/src/pullbox/services/metadata_source_reads.py @@ -110,11 +110,13 @@ async def _read[T]( registry: MetadataSourceRegistry, source: MetadataSource, capability: SourceCapability, - operation: Callable[[MetadataSourceAdapter], Awaitable[MetadataFetch[T]]], + operation: Callable[[MetadataSourceAdapter, str | None], Awaitable[MetadataFetch[T]]], validate: Callable[[T], None], validator: str | None, *, deadline: float | None = None, + cache_identity: tuple[str, int] | None = None, + model: type[MetadataFetch[T]] | None = None, ) -> MetadataFetch[T]: if validator is not None and ( not isinstance(validator, str) @@ -125,19 +127,55 @@ async def _read[T]( unavailable = registry._unavailable(source, capability=capability) if unavailable is not None: return MetadataFetch(status=unavailable) + deadline = ( + deadline + if deadline is not None + else asyncio.get_running_loop().time() + registry.total_timeout + ) + runtime = registry.runtime[source] + if ( + registry.read_cache is not None + and cache_identity is not None + and model is not None + and validator is None + and runtime.policy.revision > 0 + and runtime.credential is not None + and source + in {MetadataSource.COMICVINE_API, MetadataSource.METRON_API, MetadataSource.GCD_API_V2} + ): + + async def load(current: str | None) -> MetadataFetch[T]: + return await _read( + registry, source, capability, operation, validate, current, deadline=deadline + ) + + try: + async with asyncio.timeout_at(deadline): + result = await registry.read_cache.get( + source, + runtime.policy.revision, + capability, + *cache_identity, + model, + load, + validate, + conditional=SourceCapability.CONDITIONAL_REFRESH + in registry.factories[source].capabilities, + revalidate=registry.revalidate_reads, + ) + except TimeoutError: + return MetadataFetch(status=SourceStatus.TIMEOUT) + if result.data is not None: + validate(result.data) + return result adapter = None try: - deadline = ( - deadline - if deadline is not None - else asyncio.get_running_loop().time() + registry.total_timeout - ) if asyncio.get_running_loop().time() >= deadline: return MetadataFetch(status=SourceStatus.TIMEOUT) async with asyncio.timeout_at(deadline), registry.read_slots: async with asyncio.timeout(registry.per_source_timeout): adapter = registry.factories[source].factory(registry.runtime[source]) - result = await operation(adapter) + result = await operation(adapter, validator) if result.status is SourceStatus.OK: if result.data is None: raise ValueError("Missing source metadata") @@ -176,10 +214,12 @@ async def read_series( ) -> MetadataFetch[ProviderSeriesRead]: identifier = source_id(source, MetadataEntityKind.SERIES, external_id) - async def operation(adapter: MetadataSourceAdapter) -> MetadataFetch[ProviderSeriesRead]: + async def operation( + adapter: MetadataSourceAdapter, current: str | None + ) -> MetadataFetch[ProviderSeriesRead]: if not isinstance(adapter, SeriesDetailAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) - return await adapter.series(identifier, validator=validator) + return await adapter.series(identifier, validator=current) def validate(row: ProviderSeriesRead) -> None: _identity(row, source, MetadataEntityKind.SERIES) @@ -193,6 +233,8 @@ def validate(row: ProviderSeriesRead) -> None: operation, validate, validator, + cache_identity=(identifier, 1), + model=MetadataFetch[ProviderSeriesRead], ) @@ -205,10 +247,12 @@ async def read_issue( ) -> MetadataFetch[ProviderIssueRead]: identifier = source_id(source, MetadataEntityKind.ISSUE, external_id) - async def operation(adapter: MetadataSourceAdapter) -> MetadataFetch[ProviderIssueRead]: + async def operation( + adapter: MetadataSourceAdapter, current: str | None + ) -> MetadataFetch[ProviderIssueRead]: if not isinstance(adapter, IssueDetailAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) - return await adapter.issue(identifier, validator=validator) + return await adapter.issue(identifier, validator=current) def validate(row: ProviderIssueRead) -> None: _issue(row, source) @@ -222,6 +266,8 @@ def validate(row: ProviderIssueRead) -> None: operation, validate, validator, + cache_identity=(identifier, 1), + model=MetadataFetch[ProviderIssueRead], ) @@ -238,10 +284,11 @@ async def read_issues( async def operation( adapter: MetadataSourceAdapter, + current: str | None, ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: if not isinstance(adapter, IssueListAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) - return await adapter.issues(identifier, page=page, validator=validator) + return await adapter.issues(identifier, page=page, validator=current) def validate(result: MetadataPage[ProviderIssueRead]) -> None: validate_metadata_page(result, page) @@ -257,6 +304,8 @@ def validate(result: MetadataPage[ProviderIssueRead]) -> None: operation, validate, validator, + cache_identity=(identifier, page), + model=MetadataFetch[MetadataPage[ProviderIssueRead]], ) @@ -305,10 +354,12 @@ async def read_story_arc( ) -> MetadataFetch[ProviderStoryArcRead]: identifier = source_id(source, MetadataEntityKind.STORY_ARC, external_id) - async def operation(adapter: MetadataSourceAdapter) -> MetadataFetch[ProviderStoryArcRead]: + async def operation( + adapter: MetadataSourceAdapter, current: str | None + ) -> MetadataFetch[ProviderStoryArcRead]: if not isinstance(adapter, StoryArcDetailAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) - return await adapter.story_arc(identifier, validator=validator) + return await adapter.story_arc(identifier, validator=current) def validate(row: ProviderStoryArcRead) -> None: _arc(row, source) @@ -316,7 +367,14 @@ def validate(row: ProviderStoryArcRead) -> None: raise ValueError("Source returned a different story arc") return await _read( - registry, source, SourceCapability.STORY_ARC_DETAILS, operation, validate, validator + registry, + source, + SourceCapability.STORY_ARC_DETAILS, + operation, + validate, + validator, + cache_identity=(identifier, 1), + model=MetadataFetch[ProviderStoryArcRead], ) @@ -335,10 +393,11 @@ async def read_story_arc_issues( async def operation( adapter: MetadataSourceAdapter, + current: str | None, ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: if not isinstance(adapter, StoryArcIssueAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) - return await adapter.story_arc_issues(identifier, page=page, validator=validator) + return await adapter.story_arc_issues(identifier, page=page, validator=current) def validate(result: MetadataPage[ProviderIssueRead]) -> None: validate_metadata_page(result, page) @@ -348,7 +407,14 @@ def validate(result: MetadataPage[ProviderIssueRead]) -> None: _issue(row, source) return await _read( - registry, source, SourceCapability.STORY_ARC_ISSUES, operation, validate, validator + registry, + source, + SourceCapability.STORY_ARC_ISSUES, + operation, + validate, + validator, + cache_identity=(identifier, page), + model=MetadataFetch[MetadataPage[ProviderIssueRead]], ) @@ -362,6 +428,7 @@ async def read_story_arcs( ) -> MetadataFetch[MetadataPage[ProviderStoryArcRead]]: async def operation( adapter: MetadataSourceAdapter, + current: str | None, ) -> MetadataFetch[MetadataPage[ProviderStoryArcRead]]: if not isinstance(adapter, StoryArcSearchAdapter): raise MetadataSourceError(SourceStatus.UNSUPPORTED) diff --git a/src/pullbox/tasks/story_arc_metadata_task.py b/src/pullbox/tasks/story_arc_metadata_task.py index a8f0eae9..7a6c337e 100644 --- a/src/pullbox/tasks/story_arc_metadata_task.py +++ b/src/pullbox/tasks/story_arc_metadata_task.py @@ -23,6 +23,7 @@ from pullbox.services.metadata_arc_catalog import StoryArcSourceError, fetch_source_arc_catalog from pullbox.services.metadata_arc_commands import catalog_writer from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_sources import load_source_runtime from pullbox.services.story_arc_catalog_types import StoryArcCatalogError from pullbox.services.story_arc_service import StoryArcServiceError @@ -100,7 +101,10 @@ async def _targets( async def _registry(session: AsyncSession) -> MetadataSourceRegistry: flag = get_settings().metadata_gcd_api_v2_enabled return MetadataSourceRegistry( - await load_source_runtime(session, gcd_api_enabled=flag), gcd_api_enabled=flag + await load_source_runtime(session, gcd_api_enabled=flag), + gcd_api_enabled=flag, + read_cache=source_read_cache(session), + revalidate_reads=True, ) diff --git a/tests/api/test_source_arc_catalog_api.py b/tests/api/test_source_arc_catalog_api.py index 3a622f1d..e92a441f 100644 --- a/tests/api/test_source_arc_catalog_api.py +++ b/tests/api/test_source_arc_catalog_api.py @@ -53,6 +53,11 @@ async def handle(request): await fixture["on_request"]() if fixture["failure"] == request.url.path: return httpx.Response(429, headers={"Retry-After": "60"}) + headers = ( + {"Last-Modified": "Mon, 28 Sep 2026 10:00:00 GMT"} if fixture.get("conditional") else {} + ) + if headers and request.headers.get("If-Modified-Since") == headers["Last-Modified"]: + return httpx.Response(304) if request.url.path == "/api/arc/": page = int(request.url.params["page"]) rows = fixture["arc_rows"] @@ -67,12 +72,13 @@ async def handle(request): ), ) if request.url.path == "/api/arc/4/": - return httpx.Response(200, json={"id": 4, "name": "Native arc"}) + return httpx.Response(200, json={"id": 4, "name": "Native arc"}, headers=headers) if request.url.path == "/api/arc/4/issue_list/": page = int(request.url.params["page"]) rows = fixture["issues"] return httpx.Response( 200, + headers=headers, json=envelope( rows[(page - 1) * 100 : page * 100], count=len(rows), @@ -82,7 +88,7 @@ async def handle(request): ), ) assert request.url.path == "/api/series/8/", "Must not fetch whole parent catalogs" - return httpx.Response(200, json={**series_row(8), "name": "Parent eight"}) + return httpx.Response(200, json={**series_row(8), "name": "Parent eight"}, headers=headers) adapter = sources.MetronSource @@ -110,6 +116,24 @@ async def preview(client, fixture): return result.json() +async def test_catalog_add_revalidates_saved_response_through_real_adapter( + authenticated_client, arc_command_setup +): + fixture = arc_command_setup + fixture["conditional"] = True + snapshot = await preview(authenticated_client, fixture) + assert len(fixture["calls"]) == 3 + result = await authenticated_client.post( + BASE, json=decision(fixture, snapshot), headers=csrf(authenticated_client) + ) + assert result.status_code == 201, result.text + assert len(fixture["calls"]) == 6 + assert all( + call.headers.get("If-Modified-Since") == "Mon, 28 Sep 2026 10:00:00 GMT" + for call in fixture["calls"][3:] + ) + + def decision(fixture, snapshot): return { **fixture["selection"], diff --git a/tests/integration/metadata_identity/test_source_read_cache.py b/tests/integration/metadata_identity/test_source_read_cache.py new file mode 100644 index 00000000..2b3666d5 --- /dev/null +++ b/tests/integration/metadata_identity/test_source_read_cache.py @@ -0,0 +1,338 @@ +"""Durable source response reuse never stands in for identity verification.""" + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import func, select, update + +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models.provider_cache import MetadataProviderCacheEntry as Entry +from pullbox.schemas.metadata_sources import ( + MetadataFetch, + ProviderSeriesRead, + SourceCapability, + SourceStatus, +) +from pullbox.services.metadata_read_cache import MetadataReadCache + +MODIFIED = "Mon, 28 Sep 2026 10:00:00 GMT" + + +def response(source=Source.METRON_API, identifier="42", title="Remote series"): + return MetadataFetch[ProviderSeriesRead]( + status=SourceStatus.OK, + validator=MODIFIED, + data=ProviderSeriesRead( + source=source, + identity_namespace=source.identity_namespace, + external_id=identifier, + title=title, + ), + ) + + +def read(cache, loader, *, source=Source.METRON_API, revision=1, page=1, revalidate=False): + def validate(row): + assert row.source is source and row.external_id == "42" + assert row.title + + return cache.get( + source, + revision, + SourceCapability.SERIES_DETAILS, + "42", + page, + MetadataFetch[ProviderSeriesRead], + loader, + validate, + conditional=True, + revalidate=revalidate, + ) + + +async def test_restart_reuses_snapshot_then_revalidates_outside_transactions(identity_probe_db): + engine, factory, _ = identity_probe_db + now = datetime.now(UTC) + calls = [] + + async def load(validator): + assert engine.pool.checkedout() == 0 + calls.append(validator) + if validator: + return MetadataFetch(status=SourceStatus.NOT_MODIFIED, validator=validator) + return response() + + first = await read(MetadataReadCache(factory, now=lambda: now), load) + first.data.title = "Local mutation" + second = await read(MetadataReadCache(factory, now=lambda: now), load) + assert calls == [None] + assert second.data.title == "Remote series" + now += timedelta(minutes=6) + third = await read(MetadataReadCache(factory, now=lambda: now), load) + assert third.status is SourceStatus.OK and third.data == second.data + assert calls == [None, MODIFIED] + async with factory() as session: + row = await session.scalar(select(Entry)) + assert row.fetched_at == now + assert row.provider_name == "metron_api" + + +async def test_revision_source_and_page_are_separate_cache_keys(identity_probe_db): + _, factory, _ = identity_probe_db + cache = MetadataReadCache(factory) + calls = [] + for source, revision, page in [ + (Source.METRON_API, 1, 1), + (Source.METRON_API, 2, 1), + (Source.METRON_API, 2, 2), + (Source.COMICVINE_API, 2, 2), + ]: + + async def load(validator, source=source): + calls.append(validator) + return response(source) + + await read(cache, load, source=source, revision=revision, page=page) + await read(cache, load, source=source, revision=revision, page=page) + assert calls == [None] * 4 + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Entry)) == 4 + + +async def test_explicit_refresh_checks_upstream_and_outage_never_becomes_cached_success( + identity_probe_db, +): + _, factory, _ = identity_probe_db + cache = MetadataReadCache(factory) + calls = [] + outcome = response() + + async def load(validator): + calls.append(validator) + return outcome + + await read(cache, load) + outcome = MetadataFetch(status=SourceStatus.RATE_LIMITED, retry_after_seconds=60) + failed = await read(cache, load, revalidate=True) + assert failed.status is SourceStatus.RATE_LIMITED and failed.data is None + outcome = response(title="Updated") + updated = await read(cache, load, revalidate=True) + assert updated.data.title == "Updated" + assert calls == [None, MODIFIED, MODIFIED] + + +async def test_missing_is_short_lived_and_not_modified_without_snapshot_is_rejected( + identity_probe_db, +): + _, factory, _ = identity_probe_db + now = datetime.now(UTC) + cache = MetadataReadCache(factory, now=lambda: now) + calls = [] + outcome = MetadataFetch(status=SourceStatus.NOT_FOUND) + + async def load(validator): + calls.append(validator) + return outcome + + await read(cache, load) + await read(cache, load) + assert len(calls) == 1 + now += timedelta(seconds=31) + outcome = MetadataFetch(status=SourceStatus.NOT_MODIFIED, validator=MODIFIED) + result = await read(cache, load) + assert result.status is SourceStatus.INCOMPATIBLE_RESPONSE + assert calls == [None, None] + + +async def test_corrupt_cache_is_ignored_not_given_to_consumer(identity_probe_db): + _, factory, _ = identity_probe_db + cache = MetadataReadCache(factory) + calls = [] + + async def load(validator): + calls.append(validator) + return response() + + await read(cache, load) + async with factory.begin() as session: + await session.execute( + update(Entry).values(payload={"status": "ok", "data": {"external_id": "99"}}) + ) + assert (await read(cache, load)).data.external_id == "42" + assert calls == [None, None] + + +async def test_concurrent_requests_share_fetch_but_one_cancel_does_not_cancel_other( + identity_probe_db, +): + _, factory, _ = identity_probe_db + started, finish = asyncio.Event(), asyncio.Event() + calls = [] + + async def load(validator): + calls.append(validator) + started.set() + await finish.wait() + return response() + + first = asyncio.create_task(read(MetadataReadCache(factory), load)) + second = asyncio.create_task(read(MetadataReadCache(factory), load)) + try: + await asyncio.wait_for(started.wait(), 2) + await asyncio.sleep(0.05) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + finish.set() + assert (await second).data.title == "Remote series" + assert calls == [None] + finally: + first.cancel() + second.cancel() + await asyncio.gather(first, second, return_exceptions=True) + + +async def test_last_cancel_drains_request_and_does_not_store(identity_probe_db): + _, factory, _ = identity_probe_db + started, stopped = asyncio.Event(), asyncio.Event() + + async def load(validator): + started.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + + running = asyncio.create_task(read(MetadataReadCache(factory), load)) + try: + await asyncio.wait_for(started.wait(), 2) + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + assert stopped.is_set() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Entry)) == 0 + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + + +async def test_storage_limits_do_not_evict_legacy_cache_or_save_oversized_entries( + identity_probe_db, monkeypatch +): + from pullbox.services import metadata_read_cache as cache_module + + _, factory, _ = identity_probe_db + monkeypatch.setattr(cache_module, "_MAX_ENTRIES", 2) + now = datetime.now(UTC) + async with factory.begin() as session: + session.add( + Entry( + provider_name="comicvine", + cache_kind="get_series", + cache_key="legacy", + request={}, + payload={}, + fetched_at=now, + expires_at=now + timedelta(days=1), + ) + ) + cache = MetadataReadCache(factory, now=lambda: now) + + async def load(validator): + return response() + + for revision in range(1, 5): + await read(cache, load, revision=revision) + now += timedelta(seconds=1) + async with factory() as session: + entries = list(await session.scalars(select(Entry))) + assert len(entries) == 3 + assert any(entry.cache_key == "legacy" for entry in entries) + assert sorted(entry.request["revision"] for entry in entries if entry.request) == [3, 4] + + async def huge(validator): + return response(title="x" * (257 * 1024)) + + assert (await read(cache, huge, revision=5)).status is SourceStatus.OK + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Entry)) == 3 + + +async def test_registry_cache_is_inert_for_disabled_source_and_explicit_validator_stays_raw( + identity_probe_db, +): + from pydantic import SecretStr + + from pullbox.services.metadata_discovery import MetadataSourceRegistry + from pullbox.services.metadata_sources import SourceRuntime + from tests.unit.test_metadata_discovery import Adapter, registration, runtime + + _, factory, _ = identity_probe_db + calls = [] + + class Detail(Adapter): + async def series(self, identifier, *, validator=None): + calls.append(validator) + if validator: + return MetadataFetch(status=SourceStatus.NOT_MODIFIED, validator=validator) + return response() + + adapter = Detail(Source.METRON_API) + policy = runtime(Source.METRON_API, revision=1).policy + current = SourceRuntime(policy, SecretStr("test-source-cache-token")) + registry = MetadataSourceRegistry( + [current], + read_cache=MetadataReadCache(factory), + factories={Source.METRON_API: registration(adapter, capabilities=list(SourceCapability))}, + ) + await registry.series(Source.METRON_API, "42") + await registry.series(Source.METRON_API, "42") + assert calls == [None] + policy.enabled = False + assert (await registry.series(Source.METRON_API, "42")).status is SourceStatus.DISABLED + assert calls == [None] + policy.enabled = True + raw = await registry.series(Source.METRON_API, "42", validator=MODIFIED) + assert raw.status is SourceStatus.NOT_MODIFIED and raw.data is None + assert calls == [None, MODIFIED] + assert adapter.closed == 2 + + +async def test_cache_wait_is_part_of_registry_deadline(identity_probe_db, monkeypatch): + from pydantic import SecretStr + + from pullbox.services.metadata_discovery import MetadataSourceRegistry + from pullbox.services.metadata_sources import SourceRuntime + from tests.unit.test_metadata_discovery import Adapter, registration, runtime + + _, factory, _ = identity_probe_db + cache = MetadataReadCache(factory) + stopped = asyncio.Event() + + async def slow(*args): + try: + await asyncio.sleep(0.3) + return None + finally: + stopped.set() + + monkeypatch.setattr(cache, "_load", slow) + adapter = Adapter(Source.METRON_API) + registry = MetadataSourceRegistry( + [ + SourceRuntime( + runtime(Source.METRON_API, revision=1).policy, SecretStr("test-cache-deadline") + ) + ], + factories={Source.METRON_API: registration(adapter, capabilities=list(SourceCapability))}, + read_cache=cache, + total_timeout=0.03, + ) + started = asyncio.get_running_loop().time() + result = await registry.series(Source.METRON_API, "42") + assert result.status is SourceStatus.TIMEOUT + assert asyncio.get_running_loop().time() - started < 0.2 + assert stopped.is_set() + assert adapter.closed == 0 From 053add9da3e7b6e9dafcd266e974542b7a5eea4a Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 12:18:23 -0700 Subject: [PATCH 41/97] test(metadata): register source-aware story arc routes The source-bound preview and Add routes are intentional additions. Extend the ordered route contract without dropping existing route assertions. --- tests/ui/test_ui_router_contract.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/ui/test_ui_router_contract.py b/tests/ui/test_ui_router_contract.py index 7c1156d1..7b85e176 100644 --- a/tests/ui/test_ui_router_contract.py +++ b/tests/ui/test_ui_router_contract.py @@ -416,6 +416,18 @@ def test_ui_router_manifest_remains_stable_during_decomposition() -> None: "story_arc_catalog_refresh", "story_arc_catalog_refresh", ), + ( + "/story-arcs/catalog/{source}/{provider_id}", + ("GET",), + "source_arc_preview", + "source_arc_preview", + ), + ( + "/story-arcs/catalog/{source}/{provider_id}", + ("POST",), + "source_arc_add", + "source_arc_add", + ), ("/story-arcs", ("GET",), "story_arc_list", "story_arc_list"), ("/story-arcs/add", ("GET",), "story_arc_add", "story_arc_add"), ("/story-arcs", ("POST",), "story_arc_create", "story_arc_create"), From 39ed94f3e01657bac62f5fd0e2b4cf8a82f2b8b1 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 12:23:51 -0700 Subject: [PATCH 42/97] feat(metadata): assemble canonical source refresh snapshots Apply field authority, retain user overrides and provenance, and reject exact identity conflicts before mutation. Route new series adoption through shared assembly and add a bounded read-only refresh cascade for exact source identities. --- docs/development/ARCHITECTURE_OVERVIEW.md | 19 ++ src/pullbox/schemas/metadata_snapshot.py | 91 ++++++ src/pullbox/services/metadata_assembly.py | 204 +++++++++++++ .../services/metadata_refresh_snapshot.py | 174 +++++++++++ .../services/metadata_series_adoption.py | 75 +++-- .../metadata_identity/test_series_adoption.py | 34 +++ tests/unit/test_metadata_assembly.py | 281 ++++++++++++++++++ tests/unit/test_metadata_refresh_snapshot.py | 175 +++++++++++ 8 files changed, 1035 insertions(+), 18 deletions(-) create mode 100644 src/pullbox/schemas/metadata_snapshot.py create mode 100644 src/pullbox/services/metadata_assembly.py create mode 100644 src/pullbox/services/metadata_refresh_snapshot.py create mode 100644 tests/unit/test_metadata_assembly.py create mode 100644 tests/unit/test_metadata_refresh_snapshot.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 6b18ed59..8aa155e1 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -351,6 +351,25 @@ coalesces at most eight concurrent reads per database, and drains cancelled work Availability checks precede cache reads; local catalog reads stay generation-bound in their existing reader rather than entering this remote-response cache. +`schemas/metadata_snapshot.py` and `services/metadata_assembly.py` define a +versioned descriptive snapshot with per-field source, domain, observation time +and override markers. New source-aware series adoption uses that assembler for +series/issue descriptive fields, after complete-catalog and identity checks. +Snapshots distinguish verified identities from observed crosswalks. Exact-ID or +issue-parent disagreement stops assembly; priority cannot resolve it. Local edits, +including intentional clears, are preserved. Background assembly fills gaps; +explicit refresh can replace tracked provider-managed values under domain priority. + +`services/metadata_refresh_snapshot.py` supplies the bounded read phase for +series, issue and Story Arc descriptive refresh. It requests only known exact +identities, revalidates cached responses, cascades by requested domain, stops +unnecessary lower-priority reads and retains typed partial outcomes. It does not +write metadata, attach identities or change catalog membership. The caller must +revalidate ownership and all captured source-policy revisions before applying it. +Durable snapshot storage, ordinary refresh command/task wiring, the remaining +credits/membership fields and coordinated XML/sidecar writers are still pending; +an in-memory assembly result is not evidence that those workflows are complete. + The existing import/cache and ordinary series-refresh consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py new file mode 100644 index 00000000..7535317e --- /dev/null +++ b/src/pullbox/schemas/metadata_snapshot.py @@ -0,0 +1,91 @@ +"""Canonical descriptive values and their provenance, independent of output format.""" + +from datetime import date +from typing import Literal, Self + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.schemas.metadata_sources import MetadataDomain + + +def field_domain(kind: MetadataEntityKind, field: str) -> MetadataDomain: + if field == "image_url": + return MetadataDomain.ARTWORK + if kind is MetadataEntityKind.STORY_ARC: + return MetadataDomain.STORY_ARCS + if field == "issue_count": + return MetadataDomain.ISSUES + return MetadataDomain.CORE + + +class MetadataValues(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + title: str | None = Field(default=None, max_length=500) + sort_title: str | None = Field(default=None, max_length=500) + publisher: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=200000) + year_start: int | None = Field(default=None, ge=1, le=9999, strict=True) + year_end: int | None = Field(default=None, ge=1, le=9999, strict=True) + volume: str | None = Field(default=None, max_length=100) + series_type: str | None = Field(default=None, max_length=50) + status: str | None = Field(default=None, max_length=50) + language: str | None = Field(default=None, max_length=100) + issue_count: int | None = Field(default=None, ge=0, le=1000000, strict=True) + issue_number_text: str | None = Field(default=None, max_length=320) + cover_date: date | None = None + store_date: date | None = None + page_count: int | None = Field(default=None, ge=0, le=1000000, strict=True) + image_url: str | None = Field(default=None, max_length=500) + + +class FieldOrigin(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + field: str + domain: MetadataDomain + source: MetadataSource | None = None + source_updated_at: AwareDatetime | None = None + observed_at: AwareDatetime + user_override: bool = False + + +class MetadataSnapshot(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal[1] = 1 + entity_kind: MetadataEntityKind + identities: tuple[ExternalIdentityRef, ...] + values: MetadataValues + origins: tuple[FieldOrigin, ...] = () + observed_identities: tuple[ExternalIdentityRef, ...] = () + diagnostics: tuple[str, ...] = () + + @model_validator(mode="after") + def consistent_provenance(self) -> Self: + identities: dict[str, ExternalIdentityRef] = {} + for identity in (*self.identities, *self.observed_identities): + if identity.entity_kind is not self.entity_kind or ( + identity.namespace in identities and identities[identity.namespace] != identity + ): + raise ValueError("Snapshot identities disagree") + identities[identity.namespace] = identity + fields = set() + for origin in self.origins: + if ( + origin.field not in MetadataValues.model_fields + or origin.field in fields + or origin.domain is not field_domain(self.entity_kind, origin.field) + or (origin.user_override and origin.source is not None) + or ( + origin.source is not None + and not any( + identity.namespace is origin.source.identity_namespace + for identity in self.identities + ) + ) + ): + raise ValueError("Snapshot field provenance is inconsistent") + fields.add(origin.field) + return self diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py new file mode 100644 index 00000000..caeab931 --- /dev/null +++ b/src/pullbox/services/metadata_assembly.py @@ -0,0 +1,204 @@ +"""Assemble one identity-bound snapshot before any database or archive mutation.""" + +from collections.abc import Sequence +from datetime import UTC, datetime + +from pullbox.core.issue_numbers import parse_issue_number_text +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.schemas.metadata_snapshot import ( + FieldOrigin, + MetadataSnapshot, + MetadataValues, + field_domain, +) +from pullbox.schemas.metadata_sources import ( + MetadataDomain, + ProviderIssueRead, + ProviderSeriesRead, + ProviderStoryArcRead, + SourcePolicyRead, +) +from pullbox.services.provider_artwork import allowed_artwork_url + +type MetadataCandidate = ProviderSeriesRead | ProviderIssueRead | ProviderStoryArcRead + + +class MetadataAssemblyError(ValueError): + """Evidence cannot safely describe one canonical entity.""" + + +def _missing(value: object) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) + + +def _identity_map( + identities: Sequence[ExternalIdentityRef], kind: MetadataEntityKind +) -> dict[str, ExternalIdentityRef]: + result: dict[str, ExternalIdentityRef] = {} + for identity in identities: + if identity.entity_kind is not kind or ( + identity.namespace in result and result[identity.namespace] != identity + ): + raise MetadataAssemblyError("Exact identities disagree. Review the match.") + result[identity.namespace] = identity + return result + + +def _candidate_values( + kind: MetadataEntityKind, candidate: MetadataCandidate, diagnostics: set[str] +) -> MetadataValues: + expected = { + MetadataEntityKind.SERIES: ProviderSeriesRead, + MetadataEntityKind.ISSUE: ProviderIssueRead, + MetadataEntityKind.STORY_ARC: ProviderStoryArcRead, + }[kind] + if not isinstance(candidate, expected): + raise MetadataAssemblyError("Metadata belongs to a different entity kind.") + values = { + key: value + for key, value in candidate.model_dump().items() + if key in MetadataValues.model_fields + } + if candidate.image_url and ( + candidate.source in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2} + or not allowed_artwork_url(candidate.image_url) + ): + values["image_url"] = None + diagnostics.add(f"{candidate.source.value}:invalid_artwork_url") + return MetadataValues.model_validate(values) + + +def assemble_metadata( + kind: MetadataEntityKind, + identities: Sequence[ExternalIdentityRef], + candidates: Sequence[MetadataCandidate], + policies: Sequence[SourcePolicyRead], + *, + now: datetime, + current: MetadataValues | None = None, + previous: MetadataSnapshot | None = None, + overrides: frozenset[str] = frozenset(), + replace_managed: bool = False, + parent_identities: Sequence[ExternalIdentityRef] = (), + fields: frozenset[str] | None = None, +) -> MetadataSnapshot: + """Apply authority only after identity agreement; never attach observed crosswalks. + + Unknown existing values are local, not implicitly provider-managed. A changed + or cleared value relative to the last snapshot becomes a durable override. + Callers own proof of the supplied identities and persistence of the result. + """ + if now.tzinfo is None or now.utcoffset() is None: + raise MetadataAssemblyError("Assembly requires an aware observation timestamp.") + now = now.astimezone(UTC) + if (overrides | (fields or frozenset())) - MetadataValues.model_fields.keys(): + raise MetadataAssemblyError("Unknown metadata override field.") + verified = _identity_map(identities, kind) + parents = _identity_map(parent_identities, MetadataEntityKind.SERIES) + if previous is not None and ( + previous.entity_kind is not kind or not set(previous.identities) <= set(identities) + ): + raise MetadataAssemblyError("The previous snapshot belongs to different identities.") + policy = {item.source: item for item in policies} + if len(policy) != len(policies): + raise MetadataAssemblyError("Metadata source policies repeat a source.") + diagnostics: set[str] = set() + evidence = dict(verified) + normalized: dict[MetadataSource, tuple[MetadataCandidate, MetadataValues]] = {} + designations: set[str] = set() + for candidate in candidates: + source = candidate.source + native = ExternalIdentityRef(source.identity_namespace, kind, candidate.external_id) + if ( + candidate.identity_namespace is not native.namespace + or candidate.external_id != native.external_id + or verified.get(native.namespace) != native + ): + raise MetadataAssemblyError("Metadata requires a verified exact identity.") + if source not in policy or not policy[source].enabled or source in normalized: + raise MetadataAssemblyError("Metadata requires one enabled policy per source.") + for claim in candidate.cross_identities: + if claim.entity_kind is not kind or ( + claim.namespace in evidence and evidence[claim.namespace] != claim + ): + raise MetadataAssemblyError("Exact identities disagree. Review the match.") + evidence[claim.namespace] = claim + if isinstance(candidate, ProviderIssueRead): + parent = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.SERIES, candidate.series_external_id + ) + if parents.get(parent.namespace) != parent: + raise MetadataAssemblyError("Issue metadata requires independent parent proof.") + try: + _, designation = parse_issue_number_text(candidate.issue_number_text) + except ValueError as exc: + raise MetadataAssemblyError("Unsupported issue designation.") from exc + designations.add(designation.casefold()) + normalized[source] = (candidate, _candidate_values(kind, candidate, diagnostics)) + if len(designations) > 1: + raise MetadataAssemblyError("Issue designations disagree. Review the issue match.") + + current = current or (previous.values if previous else MetadataValues()) + if current.image_url and not allowed_artwork_url(current.image_url): + raise MetadataAssemblyError("Existing artwork requires a supported public provider URL.") + origins = {item.field: item for item in previous.origins} if previous else {} + values = current.model_dump() + assembled: list[FieldOrigin] = [] + + def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: + config = policy.get(source) + return ( + (config.domain_priorities.get(domain, config.priority), source.value) + if (config and config.enabled) + else (1001, source.value) + ) + + for field, existing in values.items(): + domain = field_domain(kind, field) + prior = origins.get(field) + edited = previous is not None and existing != getattr(previous.values, field) + protected = field in overrides or edited or (prior is not None and prior.user_override) + chosen = prior or FieldOrigin(field=field, domain=domain, observed_at=now) + if protected: + chosen = FieldOrigin(field=field, domain=domain, observed_at=now, user_override=True) + elif fields is None or field in fields: + for source in sorted(normalized, key=lambda source: rank(source, domain)): + candidate, incoming = normalized[source] + value = getattr(incoming, field) + if _missing(value): + continue + managed = prior is not None and prior.source is not None + may_replace = ( + replace_managed + and managed + and prior is not None + and prior.source is not None + and rank(source, domain) <= rank(prior.source, domain) + ) + if _missing(existing) or may_replace: + values[field] = value + chosen = FieldOrigin( + field=field, + domain=domain, + source=source, + source_updated_at=candidate.source_updated_at, + observed_at=now, + ) + break + if not _missing(values[field]) or protected or prior is not None: + assembled.append(chosen) + return MetadataSnapshot( + entity_kind=kind, + identities=tuple( + sorted(set(identities), key=lambda item: (item.namespace, item.external_id)) + ), + values=MetadataValues.model_validate(values), + origins=tuple(assembled), + observed_identities=tuple( + sorted( + set(evidence.values()) - set(identities), + key=lambda item: (item.namespace, item.external_id), + ) + ), + diagnostics=tuple(sorted(diagnostics)), + ) diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py new file mode 100644 index 00000000..ccd0ca4f --- /dev/null +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -0,0 +1,174 @@ +"""Read-only refresh cascade; callers revalidate ownership before applying a snapshot.""" + +import asyncio +from collections.abc import Sequence +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues, field_domain +from pullbox.schemas.metadata_sources import ( + ProviderIssueRead, + ProviderSeriesRead, + ProviderStoryArcRead, + SourceCapability, + SourceOutcome, + SourceStatus, +) +from pullbox.services.metadata_assembly import ( + MetadataAssemblyError, + MetadataCandidate, + assemble_metadata, +) +from pullbox.services.metadata_discovery import MetadataSourceRegistry + + +class MetadataRefreshSnapshot(BaseModel): + model_config = ConfigDict(frozen=True) + snapshot: MetadataSnapshot + outcomes: tuple[SourceOutcome, ...] + revisions: dict[MetadataSource, int] + + +async def fetch_metadata_snapshot( + registry: MetadataSourceRegistry, + kind: MetadataEntityKind, + identities: Sequence[ExternalIdentityRef], + *, + now: datetime, + requested_fields: frozenset[str], + current: MetadataValues | None = None, + previous: MetadataSnapshot | None = None, + overrides: frozenset[str] = frozenset(), + replace_managed: bool = False, + parent_identities: Sequence[ExternalIdentityRef] = (), +) -> MetadataRefreshSnapshot: + """Fetch only attached identities and stop each domain at its first usable value. + + No writes, searches, identity attachment, file operations or catalog membership + changes occur here. A success is evidence for a later revision-checked write, + not permission to apply metadata after ownership/configuration has changed. + """ + fields = { + MetadataEntityKind.SERIES: ProviderSeriesRead.model_fields.keys(), + MetadataEntityKind.ISSUE: ProviderIssueRead.model_fields.keys(), + MetadataEntityKind.STORY_ARC: ProviderStoryArcRead.model_fields.keys(), + }[kind] + if not requested_fields or requested_fields - (fields & MetadataValues.model_fields.keys()): + raise ValueError("Choose supported metadata fields for this entity kind.") + # Refresh must not mistake a fresh preview cache entry for an upstream check. + reader = MetadataSourceRegistry( + list(registry.runtime.values()), + factories=registry.factories, + gcd_api_enabled=registry.gcd_api_enabled, + per_source_timeout=registry.per_source_timeout, + total_timeout=registry.total_timeout, + concurrency=registry.concurrency, + read_cache=registry.read_cache, + revalidate_reads=True, + ) + policies = [runtime.policy for runtime in reader.runtime.values()] + candidates: list[MetadataCandidate] = [] + + def assemble(*, local: bool) -> MetadataSnapshot: + return assemble_metadata( + kind, + identities, + candidates, + policies, + now=now, + current=current if local else None, + previous=previous if local else None, + overrides=overrides if local else frozenset(), + replace_managed=replace_managed, + parent_identities=parent_identities, + fields=requested_fields, + ) + + snapshot = assemble(local=True) + initial = {origin.field: origin for origin in snapshot.origins} + needed = set() + for field in requested_fields: + value = getattr(snapshot.values, field) + missing = value is None or (isinstance(value, str) and not value.strip()) + origin = initial.get(field) + if origin is not None and origin.user_override: + continue + if missing or (replace_managed and origin is not None and origin.source is not None): + needed.add(field) + capability = { + MetadataEntityKind.SERIES: SourceCapability.SERIES_DETAILS, + MetadataEntityKind.ISSUE: SourceCapability.ISSUE_DETAILS, + MetadataEntityKind.STORY_ARC: SourceCapability.STORY_ARC_DETAILS, + }[kind] + known = {identity.namespace: identity.external_id for identity in identities} + outcomes = {} + remaining = set() + for source in reader.runtime: + if source.identity_namespace not in known: + continue + status = reader._unavailable(source, capability=capability) + outcomes[source] = SourceOutcome(source=source, status=status or SourceStatus.NOT_QUERIED) + if status is None: + if kind is MetadataEntityKind.ISSUE and not any( + parent.namespace is source.identity_namespace for parent in parent_identities + ): + raise MetadataAssemblyError("Issue refresh requires verified parent identities.") + remaining.add(source) + + def rank(source: MetadataSource, field: str) -> tuple[int, str]: + policy = reader.runtime[source].policy + domain = field_domain(kind, field) + return policy.domain_priorities.get(domain, policy.priority), source.value + + revisions = {source: runtime.policy.revision for source, runtime in reader.runtime.items()} + deadline = asyncio.get_running_loop().time() + reader.total_timeout + while remaining and needed: + offered = assemble(local=False) + winners = {origin.field: origin.source for origin in offered.origins} + choices = [] + for field in needed: + winner = winners.get(field) + eligible = [ + source + for source in remaining + if ( + field != "image_url" + or source not in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2} + ) + and (winner is None or rank(source, field) < rank(winner, field)) + ] + if eligible: + source = min(eligible, key=lambda source: rank(source, field)) + choices.append((rank(source, field), source)) + if not choices: + break + _, source = min(choices) + remaining.remove(source) + identifier = known[source.identity_namespace] + try: + async with asyncio.timeout_at(deadline): + result = await ( + reader.series(source, identifier) + if kind is MetadataEntityKind.SERIES + else reader.issue(source, identifier) + if kind is MetadataEntityKind.ISSUE + else reader.story_arc(source, identifier) + ) + except TimeoutError: + outcomes[source] = SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + break + outcomes[source] = SourceOutcome( + source=source, status=result.status, retry_after_seconds=result.retry_after_seconds + ) + if result.status is SourceStatus.INCOMPATIBLE_RESPONSE: + # The read guard also uses this status for wrong native IDs/parents. + # Falling back would let provider priority conceal exact disagreement. + raise MetadataAssemblyError("Source metadata requires review before refresh.") + if result.status is SourceStatus.OK and result.data is not None: + candidates.append(result.data) + snapshot = assemble(local=True) + return MetadataRefreshSnapshot( + snapshot=snapshot, outcomes=tuple(outcomes.values()), revisions=revisions + ) diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index a393f973..72c09165 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -30,12 +30,15 @@ from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType +from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderSeriesRead, SourceStatus +from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation from pullbox.services.metadata_service import MetadataService, classify_issue_metadata from pullbox.services.metadata_source_reads import source_id +from pullbox.services.metadata_sources import read_source_policies from pullbox.services.metadata_writer_identity import metadata_write_scope logger = structlog.get_logger(__name__) @@ -65,6 +68,8 @@ class SourceSeriesBundle: class SeriesAdoptionResult: series: Series created: bool + snapshot: MetadataSnapshot | None = None + issue_snapshots: tuple[MetadataSnapshot, ...] = () async def fetch_source_series_bundle( @@ -343,21 +348,51 @@ async def _adopt( identities = _identities(profile, MetadataEntityKind.SERIES) series = await _series_owner(session, identities) created = series is None + snapshot = None + issue_snapshots: tuple[MetadataSnapshot, ...] = () if series is None: await _require_unowned_issues(session, bundle) + policies = await read_source_policies(session) + now = datetime.now(UTC) + native_parent = ExternalIdentityRef( + profile.identity_namespace, MetadataEntityKind.SERIES, profile.external_id + ) + try: + snapshot = assemble_metadata( + MetadataEntityKind.SERIES, identities, [profile], policies, now=now + ) + issue_snapshots = tuple( + assemble_metadata( + MetadataEntityKind.ISSUE, + [ + ExternalIdentityRef( + metadata.identity_namespace, + MetadataEntityKind.ISSUE, + metadata.external_id, + ) + ], + [metadata.model_copy(update={"issue_number_text": text})], + policies, + now=now, + parent_identities=[native_parent], + ) + for metadata, (_, text) in zip(bundle.issues, numbers, strict=True) + ) + except ValueError as exc: + raise SeriesAdoptionError("The source metadata cannot be assembled safely.") from exc + values = snapshot.values publisher_id = ( - await MetadataService._ensure_publisher(session, profile.publisher) - if profile.publisher + await MetadataService._ensure_publisher(session, values.publisher) + if values.publisher else None ) - now = datetime.now(UTC) series = Series( - title=profile.title, - sort_title=profile.sort_title or profile.title, - year_start=profile.year_start, - year_end=profile.year_end, - description=profile.description, - cover_url=profile.image_url, + title=values.title, + sort_title=values.sort_title or values.title, + year_start=values.year_start, + year_end=values.year_end, + description=values.description, + cover_url=values.image_url, status=SeriesStatus(profile.status) if profile.status is not None and profile.status in SeriesStatus else SeriesStatus.UNKNOWN, @@ -398,19 +433,23 @@ async def _adopt( return SeriesAdoptionResult(series, False) for offset in range(0, len(bundle.issues), 200): members = [] - for metadata, (number, text) in zip( - bundle.issues[offset : offset + 200], numbers[offset : offset + 200], strict=True + for metadata, (number, text), issue_snapshot in zip( + bundle.issues[offset : offset + 200], + numbers[offset : offset + 200], + issue_snapshots[offset : offset + 200], + strict=True, ): + issue_values = issue_snapshot.values issue = Issue( series_id=series.id, issue_number=number, issue_number_text=text, - title=metadata.title, - description=metadata.description, - release_date=metadata.cover_date, - store_date=metadata.store_date, - cover_url=metadata.image_url, - page_count=metadata.page_count, + title=issue_values.title, + description=issue_values.description, + release_date=issue_values.cover_date, + store_date=issue_values.store_date, + cover_url=issue_values.image_url, + page_count=issue_values.page_count, status=IssueStatus.WANTED if monitored else IssueStatus.SKIPPED, metadata_source=_metadata_label(metadata.source), issue_type=classify_issue_metadata(series.series_type, metadata.title)[1], @@ -444,4 +483,4 @@ async def _adopt( source=profile.source.value, issue_count=len(bundle.issues), ) - return SeriesAdoptionResult(series, True) + return SeriesAdoptionResult(series, True, snapshot, issue_snapshots) diff --git a/tests/integration/metadata_identity/test_series_adoption.py b/tests/integration/metadata_identity/test_series_adoption.py index e6256f69..3130551e 100644 --- a/tests/integration/metadata_identity/test_series_adoption.py +++ b/tests/integration/metadata_identity/test_series_adoption.py @@ -24,6 +24,7 @@ from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.publisher import Publisher from pullbox.models.series import IssueCatalogState +from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.metadata_series_adoption import ( SeriesAdoptionError, SourceSeriesBundle, @@ -92,6 +93,39 @@ async def test_new_series_without_comicvine_id_has_verified_native_identity_and_ assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 6 +async def test_new_add_returns_canonical_snapshots_matching_persisted_metadata(identity_probe_db): + _, factory, _ = identity_probe_db + data = bundle() + data.series.description = "Canonical description" + data.issues[0].cover_date = date(2026, 9, 28) + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, data) + snapshot = getattr(result, "snapshot", None) + assert isinstance(snapshot, MetadataSnapshot) + assert snapshot.values.title == result.series.title + assert snapshot.values.description == result.series.description + assert snapshot.identities == (ExternalIdentityRef(Namespace.METRON, Kind.SERIES, "42"),) + snapshots = result.issue_snapshots + issues = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert len(snapshots) == len(issues) == 6 + for item, issue in zip(snapshots, issues, strict=True): + assert item.values.title == issue.title + assert item.values.cover_date == issue.release_date + assert item.values.issue_number_text == issue.issue_number_text + + +async def test_add_filters_unsafe_artwork_before_database_write(identity_probe_db): + _, factory, _ = identity_probe_db + data = bundle() + data.series.image_url = "https://127.0.0.1/private" + data.issues[0].image_url = "file:///private" + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, data) + assert result.series.cover_url is None + issue = await session.scalar(select(Issue).order_by(Issue.id)) + assert issue.cover_url is None + + async def test_caller_rollback_removes_whole_catalog_and_evidence(identity_probe_db): _, factory, _ = identity_probe_db async with factory() as session: diff --git a/tests/unit/test_metadata_assembly.py b/tests/unit/test_metadata_assembly.py new file mode 100644 index 00000000..8e365c01 --- /dev/null +++ b/tests/unit/test_metadata_assembly.py @@ -0,0 +1,281 @@ +"""Authority cannot manufacture identity proof or erase user intent.""" + +from datetime import UTC, date, datetime + +import pytest +from pydantic import ValidationError + +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, +) +from pullbox.core.metadata_identity import ( + MetadataEntityKind as Kind, +) +from pullbox.core.metadata_identity import ( + MetadataSource as Source, +) +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import MetadataDomain as Domain +from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderStoryArcRead +from pullbox.services.metadata_assembly import MetadataAssemblyError, assemble_metadata +from tests.unit.test_metadata_discovery import row, runtime + +NOW = datetime(2026, 9, 28, 12, tzinfo=UTC) +OLDER = datetime(2026, 9, 27, 12, tzinfo=UTC) +CV, METRON = Source.COMICVINE_API, Source.METRON_API + + +def identity(source, kind=Kind.SERIES, identifier="42"): + return ExternalIdentityRef(source.identity_namespace, kind, identifier) + + +def assemble(candidates, **kwargs): + return assemble_metadata( + Kind.SERIES, + (identity(CV), identity(METRON)), + candidates, + kwargs.pop("policies", [runtime(CV).policy, runtime(METRON).policy]), + now=NOW, + **kwargs, + ) + + +def origin(snapshot, field): + return next(item for item in snapshot.origins if item.field == field) + + +def test_domain_authority_gap_fill_and_serializable_provenance(): + cv = row(CV, title="Primary title").model_copy( + update={"image_url": "https://comicvine.gamespot.com/a.jpg", "source_updated_at": OLDER} + ) + metron = row(METRON, title="Other title").model_copy( + update={"description": "Filled gap", "image_url": "https://metron.cloud/media/a.jpg"} + ) + policies = [runtime(CV).policy, runtime(METRON, domain_priorities={Domain.ARTWORK: 0}).policy] + snapshot = assemble([metron, cv], policies=policies) + assert snapshot.values.title == "Primary title" + assert snapshot.values.description == "Filled gap" + assert snapshot.values.image_url == metron.image_url + assert origin(snapshot, "title").source is CV + assert origin(snapshot, "title").source_updated_at == OLDER + assert origin(snapshot, "title").observed_at == NOW + assert origin(snapshot, "image_url").domain is Domain.ARTWORK + assert snapshot == MetadataSnapshot.model_validate_json(snapshot.model_dump_json()) + assert snapshot == assemble([cv, metron], policies=policies) + + +def test_background_fills_only_gaps_and_explicit_refresh_replaces_managed_values(): + before = assemble([row(CV, title="Original")]) + updated = row(CV, title="Updated").model_copy(update={"description": "New description"}) + background = assemble([updated], current=before.values, previous=before) + assert background.values.title == "Original" + assert background.values.description == "New description" + assert origin(background, "title") == origin(before, "title") + manual = assemble([updated], current=before.values, previous=before, replace_managed=True) + assert manual.values.title == "Updated" + + +@pytest.mark.parametrize("title", ["My title", "", None]) +def test_local_edit_including_clear_is_preserved_through_roundtrips(title): + before = assemble([row(CV)]) + current = before.values.model_copy(update={"title": title}) + result = assemble( + [row(CV, title="Replacement")], current=current, previous=before, replace_managed=True + ) + assert result.values.title == title + assert origin(result, "title").user_override + again = assemble([row(CV)], current=result.values, previous=result, replace_managed=True) + assert again.values.title == title + assert origin(again, "title").user_override + + +def test_unknown_local_values_and_explicit_empty_overrides_are_not_provider_managed(): + result = assemble( + [row(CV)], + current=MetadataValues(title="Legacy title", description=""), + overrides=frozenset({"description"}), + replace_managed=True, + ) + assert result.values.title == "Legacy title" + assert result.values.description == "" + assert origin(result, "description").user_override + with pytest.raises(MetadataAssemblyError): + assemble([], overrides=frozenset({"file_path"})) + + +def test_lower_priority_failure_fallback_cannot_replace_higher_priority_existing_fields(): + before = assemble([row(CV)]) + result = assemble( + [row(METRON, title="Fallback")], + current=before.values, + previous=before, + replace_managed=True, + ) + assert result.values.title == before.values.title + reordered = [runtime(CV, priority=90).policy, runtime(METRON, priority=1).policy] + result = assemble( + [row(METRON, title="New authority")], + policies=reordered, + current=before.values, + previous=before, + replace_managed=True, + ) + assert result.values.title == "New authority" + + +def test_missing_provider_values_do_not_erase_and_zero_is_not_missing(): + before = assemble([row(CV).model_copy(update={"issue_count": 12, "description": "Saved"})]) + result = assemble( + [row(CV).model_copy(update={"issue_count": 0, "description": " "})], + current=before.values, + previous=before, + replace_managed=True, + ) + assert result.values.description == "Saved" + assert result.values.issue_count == 0 + assert origin(result, "issue_count").domain is Domain.ISSUES + + +@pytest.mark.parametrize("source,identifier", [(METRON, "43"), (Source.GCD_LOCAL, "42")]) +def test_unverified_native_identity_cannot_enrich_same_title(source, identifier): + with pytest.raises(MetadataAssemblyError): + assemble([row(source, external_id=identifier)]) + + +def test_crosswalk_conflict_stops_all_domains_even_for_lower_priority_source(): + bad = row(METRON).model_copy(update={"cross_identities": [identity(CV, identifier="99")]}) + with pytest.raises(MetadataAssemblyError): + assemble([row(CV), bad]) + + +def test_new_crosswalk_is_observation_not_verified_snapshot_identity(): + observed = identity(Source.GCD_LOCAL, identifier="7") + result = assemble([row(METRON).model_copy(update={"cross_identities": [observed]})]) + assert observed not in result.identities + assert result.observed_identities == (observed,) + + +def test_disabled_and_unconfigured_candidate_policies_cannot_supply_values(): + for policies in ([], [runtime(METRON, enabled=False).policy]): + with pytest.raises(MetadataAssemblyError): + assemble([row(METRON)], policies=policies) + + +def test_issue_requires_independent_parent_proof_and_preserves_lettered_number(): + member = ProviderIssueRead( + source=METRON, + identity_namespace=METRON.identity_namespace, + external_id="7", + series_external_id="42", + issue_number_text="50-x", + cover_date=date(2026, 9, 1), + ) + args = (Kind.ISSUE, [identity(METRON, Kind.ISSUE, "7")], [member], [runtime(METRON).policy]) + with pytest.raises(MetadataAssemblyError): + assemble_metadata(*args, now=NOW) + result = assemble_metadata(*args, now=NOW, parent_identities=[identity(METRON)]) + assert result.values.issue_number_text == "50-x" + assert result.values.cover_date == date(2026, 9, 1) + assert result == MetadataSnapshot.model_validate_json(result.model_dump_json()) + + +def test_story_arc_core_uses_story_arc_authority_not_series_core_priority(): + arcs = [ + ProviderStoryArcRead( + source=s, identity_namespace=s.identity_namespace, external_id="42", title=s.value + ) + for s in (CV, METRON) + ] + result = assemble_metadata( + Kind.STORY_ARC, + [identity(s, Kind.STORY_ARC) for s in (CV, METRON)], + arcs, + [runtime(CV).policy, runtime(METRON, domain_priorities={Domain.STORY_ARCS: 0}).policy], + now=NOW, + ) + assert result.values.title == METRON.value + + +@pytest.mark.parametrize( + "url", + [ + "file:///secret", + "https://user:password@metron.cloud/a.jpg", + "https://127.0.0.1/a.jpg", + "https://metron.cloud/a?token=private", + ], +) +def test_unsafe_artwork_is_diagnostic_not_snapshot_data(url): + snapshot = assemble([row(METRON).model_copy(update={"image_url": url})]) + assert snapshot.values.image_url is None + assert snapshot.diagnostics == ("metron_api:invalid_artwork_url",) + assert url not in snapshot.model_dump_json() + + +def test_snapshot_rejects_other_entity_baseline_and_naive_clock(): + before = assemble([row(CV)]) + with pytest.raises(MetadataAssemblyError): + assemble( + [], previous=before.model_copy(update={"identities": (identity(CV, identifier="99"),)}) + ) + with pytest.raises(MetadataAssemblyError): + assemble_metadata(Kind.SERIES, [identity(CV)], [], [], now=NOW.replace(tzinfo=None)) + with pytest.raises(ValidationError): + before.values.title = "Mutated" + + +def test_identity_kind_and_namespace_are_not_interchangeable(): + bad = row(METRON).model_copy(update={"identity_namespace": IdentityNamespace.COMICVINE}) + with pytest.raises(MetadataAssemblyError): + assemble([bad]) + + +@pytest.mark.parametrize( + "change", ["duplicate_origin", "unknown_field", "wrong_domain", "wrong_kind"] +) +def test_saved_snapshot_rejects_inconsistent_provenance(change): + saved = assemble([row(CV)]).model_dump(mode="json") + if change == "duplicate_origin": + saved["origins"].append(saved["origins"][0]) + elif change == "unknown_field": + saved["origins"][0]["field"] = "path" + elif change == "wrong_domain": + saved["origins"][0]["domain"] = "artwork" + else: + saved["identities"][0]["entity_kind"] = "issue" + with pytest.raises(ValidationError): + MetadataSnapshot.model_validate(saved) + + +def test_untrusted_local_artwork_does_not_reach_canonical_outputs(): + with pytest.raises(MetadataAssemblyError): + assemble([], current=MetadataValues(image_url="file:///private")) + + +def test_conflicting_issue_numbers_stop_assembly_despite_shared_verified_parent(): + candidates = [ + ProviderIssueRead( + source=s, + identity_namespace=s.identity_namespace, + external_id="7", + series_external_id="42", + issue_number_text=number, + ) + for s, number in ((CV, "50-x"), (METRON, "50-o")) + ] + with pytest.raises(MetadataAssemblyError): + assemble_metadata( + Kind.ISSUE, + [identity(s, Kind.ISSUE, "7") for s in (CV, METRON)], + candidates, + [runtime(s).policy for s in (CV, METRON)], + now=NOW, + parent_identities=[identity(s) for s in (CV, METRON)], + ) + + +@pytest.mark.parametrize("field,value", [("publisher", "p" * 256), ("image_url", "u" * 501)]) +def test_canonical_values_fit_database_text_columns_on_both_backends(field, value): + with pytest.raises(ValidationError): + MetadataValues.model_validate({field: value}) diff --git a/tests/unit/test_metadata_refresh_snapshot.py b/tests/unit/test_metadata_refresh_snapshot.py new file mode 100644 index 00000000..e22ee8fc --- /dev/null +++ b/tests/unit/test_metadata_refresh_snapshot.py @@ -0,0 +1,175 @@ +"""Refresh cascades exact identities, never title-searches or fans out unnecessarily.""" + +import asyncio + +import pytest + +from pullbox.schemas.metadata_sources import MetadataFetch, SourceCapability, SourceStatus +from pullbox.services.metadata_assembly import MetadataAssemblyError +from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry +from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot +from tests.unit.test_metadata_assembly import ( + CV, + METRON, + NOW, + Domain, + Kind, + Source, + assemble, + identity, +) +from tests.unit.test_metadata_discovery import registration, row, runtime +from tests.unit.test_metadata_source_reads import ReadAdapter + + +def setup(*, metron_title="Secondary", cv_title="Primary", **options): + adapters = {} + for source, title in ((CV, cv_title), (METRON, metron_title)): + adapter = ReadAdapter( + result=MetadataFetch(status=SourceStatus.OK, data=row(source, title=title)) + ) + adapter.source = source + adapters[source] = adapter + registry = MetadataSourceRegistry( + [runtime(CV, revision=1), runtime(METRON, revision=2)], + factories={ + s: registration(a, capabilities=list(SourceCapability)) for s, a in adapters.items() + }, + **options, + ) + return registry, adapters + + +async def fetch(registry, **kwargs): + return await fetch_metadata_snapshot( + registry, + Kind.SERIES, + kwargs.pop("identities", [identity(CV), identity(METRON)]), + now=NOW, + requested_fields=kwargs.pop("fields", frozenset({"title"})), + **kwargs, + ) + + +async def test_uses_only_exact_native_identity_and_stops_when_domains_satisfied(): + registry, adapters = setup() + result = await fetch(registry) + assert result.snapshot.values.title == "Primary" + assert adapters[CV].calls == [("series", "42", None)] + assert adapters[METRON].calls == [] + assert result.revisions == {CV: 1, METRON: 2} + result = await fetch(registry, identities=[identity(METRON)]) + assert result.snapshot.values.title == "Secondary" + assert len(adapters[CV].calls) == 1 + + +async def test_separate_domain_priority_and_gap_fill_without_repeat_fetch(): + registry, adapters = setup() + registry.runtime[METRON] = runtime(METRON, revision=2, domain_priorities={Domain.ARTWORK: 0}) + adapters[METRON].result.data.image_url = "https://metron.cloud/media/cover.jpg" + adapters[METRON].result.data.description = "Description" + result = await fetch(registry, fields=frozenset({"title", "description", "image_url"})) + assert result.snapshot.values.title == "Primary" + assert result.snapshot.values.description == "Description" + assert result.snapshot.values.image_url == "https://metron.cloud/media/cover.jpg" + assert all(len(a.calls) == 1 and a.closed == 1 for a in adapters.values()) + + +async def test_failure_keeps_typed_outcome_and_falls_back_without_changing_user_value(): + registry, adapters = setup() + adapters[CV].error = MetadataSourceError(SourceStatus.RATE_LIMITED, 60) + result = await fetch(registry) + assert result.snapshot.values.title == "Secondary" + failure = next(outcome for outcome in result.outcomes if outcome.source is CV) + assert failure.status is SourceStatus.RATE_LIMITED and failure.retry_after_seconds == 60 + before = assemble([row(CV, title="Saved")]) + result = await fetch(registry, current=before.values, previous=before, replace_managed=True) + assert result.snapshot.values.title == "Saved" + + +async def test_background_already_populated_or_protected_fields_need_no_network(): + registry, adapters = setup() + before = assemble([row(CV, title="Saved")]) + result = await fetch(registry, current=before.values, previous=before) + assert result.snapshot.values.title == "Saved" + assert all(not a.calls for a in adapters.values()) + + result = await fetch( + registry, + current=before.values, + previous=before, + replace_managed=True, + overrides=frozenset({"title"}), + ) + assert result.snapshot.values.title == "Saved" + assert all(not a.calls for a in adapters.values()) + + +async def test_artwork_only_refresh_does_not_change_unrequested_descriptive_fields(): + registry, adapters = setup() + before = assemble([row(CV, title="Keep title")]) + adapters[CV].result.data.image_url = "https://comicvine.gamespot.com/new.jpg" + result = await fetch( + registry, + fields=frozenset({"image_url"}), + current=before.values, + previous=before, + replace_managed=True, + ) + assert result.snapshot.values.image_url == adapters[CV].result.data.image_url + assert result.snapshot.values.title == "Keep title" + + +async def test_disabled_and_feature_gated_sources_never_construct_clients(): + registry, adapters = setup() + registry.runtime[CV] = runtime(CV, enabled=False) + registry.runtime[Source.GCD_API_V2] = runtime(Source.GCD_API_V2) + result = await fetch( + registry, identities=[identity(CV), identity(METRON), identity(Source.GCD_API_V2)] + ) + assert result.snapshot.values.title == "Secondary" + assert not adapters[CV].calls + assert any( + o.source is Source.GCD_API_V2 and o.status is SourceStatus.FEATURE_DISABLED + for o in result.outcomes + ) + + +async def test_crosswalk_conflict_never_triggers_search_or_fallback(): + registry, adapters = setup() + adapters[CV].result.data.cross_identities = [identity(METRON, identifier="99")] + with pytest.raises(MetadataAssemblyError): + await fetch(registry) + assert not adapters[METRON].calls + + +async def test_wrong_native_response_never_falls_back_to_another_provider(): + registry, adapters = setup() + adapters[CV].result.data.external_id = "99" + with pytest.raises(MetadataAssemblyError): + await fetch(registry) + assert not adapters[METRON].calls + + +async def test_total_deadline_and_cancellation_are_bounded_and_close_reads(): + registry, adapters = setup(total_timeout=0.03) + adapters[CV].wait = asyncio.Event() + result = await fetch(registry) + assert result.snapshot.values.title is None + assert any(outcome.status is SourceStatus.TIMEOUT for outcome in result.outcomes) + assert adapters[CV].closed == 1 + registry.total_timeout = 10 + task = asyncio.create_task(fetch(registry)) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert adapters[CV].closed == 2 + + +@pytest.mark.parametrize("fields", [frozenset(), frozenset({"file_path"})]) +async def test_invalid_requested_fields_never_make_requests(fields): + registry, adapters = setup() + with pytest.raises(ValueError): + await fetch(registry, fields=fields) + assert all(not a.calls for a in adapters.values()) From e69659871d995260d26d2a3572f922600ee2346d Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 12:53:13 -0700 Subject: [PATCH 43/97] feat(metadata): persist revision-checked canonical baselines --- .../t1n2o3p4q567_add_metadata_baselines.py | 50 +++ docs/development/ARCHITECTURE_OVERVIEW.md | 18 +- docs/development/DATABASE_STANDARDS.md | 17 + src/pullbox/models/__init__.py | 8 + src/pullbox/models/metadata_baseline.py | 43 +++ src/pullbox/schemas/metadata_snapshot.py | 5 + src/pullbox/services/metadata_assembly.py | 11 +- src/pullbox/services/metadata_baselines.py | 229 ++++++++++++ .../services/metadata_refresh_snapshot.py | 6 +- src/pullbox/services/metadata_series_add.py | 9 + .../services/metadata_series_adoption.py | 88 ++++- .../test_metadata_baselines.py | 344 ++++++++++++++++++ .../test_series_add_workflow.py | 36 +- .../metadata_identity/test_series_adoption.py | 31 ++ tests/integration/test_migrations.py | 3 +- tests/unit/test_metadata_assembly.py | 49 ++- tests/unit/test_metadata_refresh_snapshot.py | 23 ++ 17 files changed, 958 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/t1n2o3p4q567_add_metadata_baselines.py create mode 100644 src/pullbox/models/metadata_baseline.py create mode 100644 src/pullbox/services/metadata_baselines.py create mode 100644 tests/integration/metadata_identity/test_metadata_baselines.py diff --git a/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py b/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py new file mode 100644 index 00000000..0fc008db --- /dev/null +++ b/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py @@ -0,0 +1,50 @@ +"""Persist canonical metadata baselines without inventing legacy provenance.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "t1n2o3p4q567" +down_revision = "s0m1n2o3p456" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + for kind, parent in (("series", "series"), ("issue", "issues"), ("story_arc", "story_arcs")): + op.create_table( + f"{kind}_metadata_baselines", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + f"{kind}_id", + sa.Integer(), + sa.ForeignKey(f"{parent}.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("snapshot_json", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint(f"{kind}_id", name=f"uq_{kind}_metadata_baseline_target"), + sa.CheckConstraint("revision > 0", name=f"ck_{kind}_metadata_baseline_revision"), + sa.CheckConstraint( + "length(snapshot_json) BETWEEN 2 AND 1048576", + name=f"ck_{kind}_metadata_baseline_size", + ), + ) + + +def downgrade() -> None: + # Downgrade discards provenance, never entities, ownership, or source files. + for kind in ("story_arc", "issue", "series"): + op.drop_table(f"{kind}_metadata_baselines") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 8aa155e1..ce220adb 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -366,9 +366,21 @@ identities, revalidates cached responses, cascades by requested domain, stops unnecessary lower-priority reads and retains typed partial outcomes. It does not write metadata, attach identities or change catalog membership. The caller must revalidate ownership and all captured source-policy revisions before applying it. -Durable snapshot storage, ordinary refresh command/task wiring, the remaining -credits/membership fields and coordinated XML/sidecar writers are still pending; -an in-memory assembly result is not evidence that those workflows are complete. +`services/metadata_baselines.py` persists versioned canonical snapshots in separate +series, issue and Story Arc tables with real cascading foreign keys. Writes are +bounded batches, use caller-owned transactions, recheck verified ownership and +require the expected baseline revision. They acquire the same parent-first locks +as identity writers; the baseline never establishes identity ownership itself. +Corrupt or incompatible snapshots fail explicitly rather than silently removing +override protection. No legacy backfill invents provenance for existing values. + +Source-aware Add Series saves series/issue baselines in its catalog transaction, +including final lifecycle/format inference. Derived values have a distinct origin, +not provider attribution or user-override markers. Explicit refresh may replace +those values; background enrichment still fills gaps only. Existing-owner Add +does not replace its baseline or local edits. Ordinary refresh command/task wiring, +the remaining credits/membership fields and coordinated XML/sidecar writers are +still pending; durable baselines alone do not complete those workflows. The existing import/cache and ordinary series-refresh consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 57e86a1b..61edce7a 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -406,6 +406,23 @@ Health checks perform no provider I/O inside a database transaction. Their short result write applies only to the tested revision and cannot replace a newer probe. Cached health is diagnostic, not proof that a source is safe or available now. +### Canonical Metadata Baselines + +Series, issue and Story Arc metadata baselines use separate real foreign keys, +one row per local target, positive revisions and bounded versioned snapshot JSON. +They are retained provenance, not a provider-response cache. Entity deletion +cascades the corresponding baseline; source configuration deletion does not. +Migration does not backfill provider ownership of existing descriptive values. + +Baseline writes run through the shared service in caller-owned transactions. +They lock arcs, series parents, then issues in ID order, verify current exact +ownership and compare the expected baseline revision before updating. Batched +creation avoids per-issue reads. SQLite obtains its write transaction before the +savepoint; PostgreSQL uses row locks. A stale batch rolls back even when its caller +catches the conflict. Future refresh writers must additionally revalidate their +captured source-policy and ownership revisions and current user values before +applying network results; the baseline service is not a substitute for that check. + ### 4.1 Base Model Mixins **Current Pullbox implementation** diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index 21daecea..29404944 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -60,6 +60,11 @@ MatchConfidence, ) from pullbox.models.matching_suggestion import MatchingSuggestion, SuggestionStatus +from pullbox.models.metadata_baseline import ( + IssueMetadataBaseline, + SeriesMetadataBaseline, + StoryArcMetadataBaseline, +) from pullbox.models.metadata_identity import ( IssueExternalIdentity, IssueIdentityEvent, @@ -179,6 +184,7 @@ "IssueCreator", "IssueExternalIdentity", "IssueIdentityEvent", + "IssueMetadataBaseline", "IssueReaderState", "IssueStatus", "IssueStoryArc", @@ -209,6 +215,7 @@ "Series", "SeriesExternalIdentity", "SeriesIdentityEvent", + "SeriesMetadataBaseline", "SeriesStatus", "SeriesStatusOverride", "SeriesType", @@ -216,6 +223,7 @@ "StoryArcExternalIdentity", "StoryArcIdentityEvent", "StoryArcLifecycle", + "StoryArcMetadataBaseline", "StoryArcPlacement", "StoryArcPlacementMode", "StoryArcPlacementOwnership", diff --git a/src/pullbox/models/metadata_baseline.py b/src/pullbox/models/metadata_baseline.py new file mode 100644 index 00000000..05e8b8e1 --- /dev/null +++ b/src/pullbox/models/metadata_baseline.py @@ -0,0 +1,43 @@ +"""Last committed canonical values; not an ownership or provider-response cache.""" + +from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin + + +class _BaselineFields: + revision: Mapped[int] = mapped_column(Integer, nullable=False) + snapshot_json: Mapped[str] = mapped_column(Text, nullable=False) + + +def _constraints(kind: str) -> tuple[CheckConstraint | UniqueConstraint, ...]: + return ( + UniqueConstraint(f"{kind}_id", name=f"uq_{kind}_metadata_baseline_target"), + CheckConstraint("revision > 0", name=f"ck_{kind}_metadata_baseline_revision"), + CheckConstraint( + "length(snapshot_json) BETWEEN 2 AND 1048576", + name=f"ck_{kind}_metadata_baseline_size", + ), + ) + + +class SeriesMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields): + __tablename__ = "series_metadata_baselines" + __table_args__ = _constraints("series") + + series_id: Mapped[int] = mapped_column(ForeignKey("series.id", ondelete="CASCADE")) + + +class IssueMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields): + __tablename__ = "issue_metadata_baselines" + __table_args__ = _constraints("issue") + + issue_id: Mapped[int] = mapped_column(ForeignKey("issues.id", ondelete="CASCADE")) + + +class StoryArcMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields): + __tablename__ = "story_arc_metadata_baselines" + __table_args__ = _constraints("story_arc") + + story_arc_id: Mapped[int] = mapped_column(ForeignKey("story_arcs.id", ondelete="CASCADE")) diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py index 7535317e..d20dcd2a 100644 --- a/src/pullbox/schemas/metadata_snapshot.py +++ b/src/pullbox/schemas/metadata_snapshot.py @@ -49,6 +49,7 @@ class FieldOrigin(BaseModel): source_updated_at: AwareDatetime | None = None observed_at: AwareDatetime user_override: bool = False + derivation: Literal["classification", "lifecycle", "catalog", "normalization"] | None = None class MetadataSnapshot(BaseModel): @@ -78,6 +79,10 @@ def consistent_provenance(self) -> Self: or origin.field in fields or origin.domain is not field_domain(self.entity_kind, origin.field) or (origin.user_override and origin.source is not None) + or ( + origin.derivation is not None + and (origin.source is not None or origin.user_override) + ) or ( origin.source is not None and not any( diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py index caeab931..651c172d 100644 --- a/src/pullbox/services/metadata_assembly.py +++ b/src/pullbox/services/metadata_assembly.py @@ -167,13 +167,16 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: value = getattr(incoming, field) if _missing(value): continue - managed = prior is not None and prior.source is not None may_replace = ( replace_managed - and managed and prior is not None - and prior.source is not None - and rank(source, domain) <= rank(prior.source, domain) + and ( + prior.derivation is not None + or ( + prior.source is not None + and rank(source, domain) <= rank(prior.source, domain) + ) + ) ) if _missing(existing) or may_replace: values[field] = value diff --git a/src/pullbox/services/metadata_baselines.py b/src/pullbox/services/metadata_baselines.py new file mode 100644 index 00000000..c0350e23 --- /dev/null +++ b/src/pullbox/services/metadata_baselines.py @@ -0,0 +1,229 @@ +"""Durable canonical metadata baselines with caller-owned transactions.""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from pydantic import ValidationError +from sqlalchemy import insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Base, Issue, Series, StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_baseline import ( + IssueMetadataBaseline, + SeriesMetadataBaseline, + StoryArcMetadataBaseline, +) +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.metadata_writer_identity import metadata_write_scope + +_MODELS: dict[ + MetadataEntityKind, + tuple[ + type[Series] | type[Issue] | type[StoryArc], + type[SeriesMetadataBaseline] | type[IssueMetadataBaseline] | type[StoryArcMetadataBaseline], + type[SeriesExternalIdentity] | type[IssueExternalIdentity] | type[StoryArcExternalIdentity], + ], +] = { + MetadataEntityKind.SERIES: (Series, SeriesMetadataBaseline, SeriesExternalIdentity), + MetadataEntityKind.ISSUE: (Issue, IssueMetadataBaseline, IssueExternalIdentity), + MetadataEntityKind.STORY_ARC: (StoryArc, StoryArcMetadataBaseline, StoryArcExternalIdentity), +} + + +class MetadataBaselineConflictError(ValueError): + """The baseline or its identity ownership changed before persistence.""" + + +@dataclass(frozen=True) +class MetadataBaselineWrite: + local_id: int + snapshot: MetadataSnapshot + expected_revision: int = 0 + + +@dataclass(frozen=True) +class SavedMetadataBaseline: + local_id: int + revision: int + snapshot: MetadataSnapshot + + +async def load_metadata_baseline( + session: AsyncSession, kind: MetadataEntityKind, local_id: int +) -> SavedMetadataBaseline | None: + _, model, _ = _MODELS[kind] + table = Base.metadata.tables[model.__tablename__] + row = ( + (await session.execute(select(table).where(table.c[f"{kind.value}_id"] == local_id))) + .mappings() + .one_or_none() + ) + if row is None: + return None + try: + snapshot = MetadataSnapshot.model_validate_json(row.snapshot_json) + if snapshot.entity_kind is not kind: + raise ValueError("Wrong entity kind") + except (ValueError, ValidationError) as exc: + raise MetadataBaselineConflictError( + "Stored metadata baseline is invalid. Review before refreshing." + ) from exc + return SavedMetadataBaseline(local_id, row.revision, snapshot) + + +async def save_metadata_baselines( + session: AsyncSession, writes: Sequence[MetadataBaselineWrite] +) -> tuple[SavedMetadataBaseline, ...]: + if not writes: + return () + if len(writes) > 200: + raise ValueError("Metadata baseline batches are limited to 200 entities") + keys = set() + payloads = [] + for item in writes: + key = (item.snapshot.entity_kind, item.local_id) + if ( + type(item.local_id) is not int + or item.local_id <= 0 + or type(item.expected_revision) is not int + or item.expected_revision < 0 + or key in keys + ): + raise ValueError("Invalid or repeated metadata baseline target") + keys.add(key) + payload = item.snapshot.model_dump_json() + MetadataSnapshot.model_validate_json(payload) + if len(payload.encode("utf-8")) > 1048576: + raise ValueError("Metadata baseline exceeds its storage limit") + payloads.append(payload) + try: + async with metadata_write_scope(session): + await _lock_targets(session, writes) + results = [] + for kind in ( + MetadataEntityKind.STORY_ARC, + MetadataEntityKind.SERIES, + MetadataEntityKind.ISSUE, + ): + group = [ + (item, payload) + for item, payload in zip(writes, payloads, strict=True) + if item.snapshot.entity_kind is kind + ] + if group: + results.extend(await _save_group(session, kind, group)) + await session.flush() + except IntegrityError as exc: + raise MetadataBaselineConflictError( + "Metadata baseline changed. Reload before retrying." + ) from exc + by_key = {(result.snapshot.entity_kind, result.local_id): result for result in results} + return tuple(by_key[(item.snapshot.entity_kind, item.local_id)] for item in writes) + + +async def _lock_targets(session: AsyncSession, writes: Sequence[MetadataBaselineWrite]) -> None: + ids = { + kind: {item.local_id for item in writes if item.snapshot.entity_kind is kind} + for kind in MetadataEntityKind + } + parents = dict( + ( + await session.execute( + select(Issue.id, Issue.series_id).where(Issue.id.in_(ids[MetadataEntityKind.ISSUE])) + ) + ) + .tuples() + .all() + ) + series_ids = ids[MetadataEntityKind.SERIES] | set(parents.values()) + # Match identity writers: arcs, series parents, then issues, each in ID order. + for kind, targets in ( + (MetadataEntityKind.STORY_ARC, ids[MetadataEntityKind.STORY_ARC]), + (MetadataEntityKind.SERIES, series_ids), + (MetadataEntityKind.ISSUE, ids[MetadataEntityKind.ISSUE]), + ): + model, _, _ = _MODELS[kind] + found = set( + await session.scalars( + select(model.id).where(model.id.in_(targets)).order_by(model.id).with_for_update() + ) + ) + if found != targets: + raise MetadataBaselineConflictError("Metadata target no longer exists.") + actual = dict( + (await session.execute(select(Issue.id, Issue.series_id).where(Issue.id.in_(parents)))) + .tuples() + .all() + ) + if actual != parents: + raise MetadataBaselineConflictError("Issue parent changed. Reload before retrying.") + + +async def _save_group( + session: AsyncSession, kind: MetadataEntityKind, group: list[tuple[MetadataBaselineWrite, str]] +) -> list[SavedMetadataBaseline]: + _, model, claim_model = _MODELS[kind] + table = Base.metadata.tables[model.__tablename__] + claims = Base.metadata.tables[claim_model.__tablename__] + key = f"{kind.value}_id" + ids = [item.local_id for item, _ in group] + saved = { + row[key]: row + for row in ( + await session.execute(select(table).where(table.c[key].in_(ids)).with_for_update()) + ).mappings() + } + verified: dict[int, set[ExternalIdentityRef]] = {local_id: set() for local_id in ids} + for claim in ( + await session.execute( + select(claims).where(claims.c[key].in_(ids)).with_for_update(read=True) + ) + ).mappings(): + namespace = ( + claim.source if kind is MetadataEntityKind.STORY_ARC else claim.identity_namespace + ) + if kind is MetadataEntityKind.STORY_ARC and ( + claim.namespace != "story_arc" or namespace not in IdentityNamespace + ): + continue + if claim.verification_state == IdentityVerificationState.VERIFIED: + verified[claim[key]].add( + ExternalIdentityRef(IdentityNamespace(namespace), kind, claim.external_id) + ) + additions = [] + results = [] + for item, payload in group: + row = saved.get(item.local_id) + if (row.revision if row else 0) != item.expected_revision or set( + item.snapshot.identities + ) != verified[item.local_id]: + raise MetadataBaselineConflictError( + "Metadata baseline or verified identities changed. Reload before retrying." + ) + values = { + key: item.local_id, + "revision": item.expected_revision + 1, + "snapshot_json": payload, + } + if row is None: + additions.append(values) + else: + await session.execute( + update(table) + .where(table.c.id == row.id, table.c.revision == item.expected_revision) + .values(**values) + ) + results.append( + SavedMetadataBaseline(item.local_id, item.expected_revision + 1, item.snapshot) + ) + if additions: + await session.execute(insert(table), additions) + return results diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py index ccd0ca4f..6ea76054 100644 --- a/src/pullbox/services/metadata_refresh_snapshot.py +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -95,7 +95,11 @@ def assemble(*, local: bool) -> MetadataSnapshot: origin = initial.get(field) if origin is not None and origin.user_override: continue - if missing or (replace_managed and origin is not None and origin.source is not None): + if missing or ( + replace_managed + and origin is not None + and (origin.source is not None or origin.derivation is not None) + ): needed.add(field) capability = { MetadataEntityKind.SERIES: SourceCapability.SERIES_DETAILS, diff --git a/src/pullbox/services/metadata_series_add.py b/src/pullbox/services/metadata_series_add.py index fed4d79a..f301984b 100644 --- a/src/pullbox/services/metadata_series_add.py +++ b/src/pullbox/services/metadata_series_add.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from dataclasses import replace from sqlalchemy.ext.asyncio import AsyncSession @@ -12,6 +13,7 @@ SeriesAdoptionResult, SourceSeriesBundle, adopt_source_series_bundle, + persist_adoption_series_baseline, ) from pullbox.services.metadata_service import ( MetadataService, @@ -60,6 +62,13 @@ async def source_series_add_transaction( series, preserve_provider_type=bundle.series.series_type is not None, ) + if result.snapshot is not None: + result = replace( + result, + snapshot=await persist_adoption_series_baseline( + session, series, result.snapshot + ), + ) if library_root_id is not None: folder = await SeriesService._create_series_folder( session, diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index 72c09165..a00daa0e 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -30,9 +30,19 @@ from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType -from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.schemas.metadata_snapshot import ( + FieldOrigin, + MetadataSnapshot, + MetadataValues, + field_domain, +) from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderSeriesRead, SourceStatus from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import ( + MetadataBaselineWrite, + load_metadata_baseline, + save_metadata_baselines, +) from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation @@ -474,9 +484,20 @@ async def _adopt( await record_identity_observation( session, _request(bundle, metadata, crosswalk, issue.id, observation=True) ) + await save_metadata_baselines( + session, + [ + MetadataBaselineWrite(issue.id, issue_snapshot) + for (issue, _), issue_snapshot in zip( + members, issue_snapshots[offset : offset + 200], strict=True + ) + ], + ) await session.flush() if profile.status is None: await MetadataService.infer_series_status(session, series) + if snapshot is not None: + snapshot = await persist_adoption_series_baseline(session, series, snapshot) logger.info( "metadata_series_added", series_id=series.id, @@ -484,3 +505,68 @@ async def _adopt( issue_count=len(bundle.issues), ) return SeriesAdoptionResult(series, True, snapshot, issue_snapshots) + + +async def persist_adoption_series_baseline( + session: AsyncSession, series: Series, snapshot: MetadataSnapshot +) -> MetadataSnapshot: + """Record Add's inferred defaults, not invented provider data or user edits.""" + values = snapshot.values.model_dump() + origins = {origin.field: origin for origin in snapshot.origins} + now = datetime.now(UTC) + derived = { + "sort_title": FieldOrigin( + field="sort_title", + domain=field_domain(MetadataEntityKind.SERIES, "sort_title"), + observed_at=now, + derivation="normalization", + ), + "series_type": FieldOrigin( + field="series_type", + domain=field_domain(MetadataEntityKind.SERIES, "series_type"), + observed_at=now, + derivation="classification", + ), + "status": FieldOrigin( + field="status", + domain=field_domain(MetadataEntityKind.SERIES, "status"), + observed_at=now, + derivation="lifecycle", + ), + "year_end": FieldOrigin( + field="year_end", + domain=field_domain(MetadataEntityKind.SERIES, "year_end"), + observed_at=now, + derivation="lifecycle", + ), + "issue_count": FieldOrigin( + field="issue_count", + domain=field_domain(MetadataEntityKind.SERIES, "issue_count"), + observed_at=now, + derivation="catalog", + ), + } + actual = { + "sort_title": series.sort_title, + "series_type": series.series_type.value, + "status": series.status.value, + "year_end": series.year_end, + "issue_count": series.issue_count, + } + for field, value in actual.items(): + if values[field] != value: + values[field] = value + origins[field] = derived[field] + snapshot = MetadataSnapshot.model_validate( + { + **snapshot.model_dump(), + "values": MetadataValues.model_validate(values), + "origins": tuple(origins.values()), + } + ) + saved = await load_metadata_baseline(session, MetadataEntityKind.SERIES, series.id) + if saved is None or saved.snapshot != snapshot: + await save_metadata_baselines( + session, [MetadataBaselineWrite(series.id, snapshot, saved.revision if saved else 0)] + ) + return snapshot diff --git a/tests/integration/metadata_identity/test_metadata_baselines.py b/tests/integration/metadata_identity/test_metadata_baselines.py new file mode 100644 index 00000000..4ce487b9 --- /dev/null +++ b/tests/integration/metadata_identity/test_metadata_baselines.py @@ -0,0 +1,344 @@ +"""Canonical provenance survives sessions without acquiring identity ownership.""" + +import asyncio +from datetime import UTC, datetime + +import pytest +from sqlalchemy import delete, select, update + +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.core.metadata_identity_state import IdentityVerificationState as State +from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import MetadataDomain +from pullbox.services.metadata_baselines import ( + MetadataBaselineConflictError, + MetadataBaselineWrite, + load_metadata_baseline, + save_metadata_baselines, +) + + +async def seed(factory, kind=Kind.SERIES): + async with factory.begin() as session: + series = Series(title="Original", sort_title="Original", path="/reference/original") + session.add(series) + await session.flush() + if kind is Kind.SERIES: + row = series + claim = SeriesExternalIdentity(series_id=row.id) + elif kind is Kind.ISSUE: + row = Issue(series_id=series.id, title="Original", issue_number=1) + session.add(row) + await session.flush() + claim = IssueExternalIdentity(issue_id=row.id) + else: + row = StoryArc(name="Original") + session.add(row) + await session.flush() + claim = StoryArcExternalIdentity( + story_arc_id=row.id, source="metron", namespace="story_arc" + ) + if kind is not Kind.STORY_ARC: + claim.identity_namespace = Namespace.METRON + claim.external_id = "42" + claim.verification_state = State.VERIFIED + claim.evidence_kind = "provider_result" + session.add(claim) + return row.id + + +def snapshot(kind=Kind.SERIES, title="Original"): + return MetadataSnapshot( + entity_kind=kind, + identities=(ExternalIdentityRef(Namespace.METRON, kind, "42"),), + values=MetadataValues(title=title), + origins=( + FieldOrigin( + field="title", + domain=MetadataDomain.STORY_ARCS if kind is Kind.STORY_ARC else MetadataDomain.CORE, + source=Source.METRON_API, + observed_at=datetime(2026, 9, 28, tzinfo=UTC), + ), + ), + ) + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_baseline_survives_new_sessions_and_database_connections(identity_probe_db, kind): + engine, factory, _ = identity_probe_db + local_id = await seed(factory, kind) + value = snapshot(kind) + async with factory.begin() as session: + result = await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, value)]) + assert len(result) == 1 and result[0].revision == 1 + await engine.dispose() + async with factory() as session: + saved = await load_metadata_baseline(session, kind, local_id) + assert saved is not None and saved.snapshot == value and saved.revision == 1 + + +async def test_baseline_write_does_not_commit_entity_changes(identity_probe_db): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + row = await session.get(Series, local_id) + row.title = "Not committed" + await session.rollback() + async with factory() as session: + assert await load_metadata_baseline(session, Kind.SERIES, local_id) is None + assert (await session.get(Series, local_id)).title == "Original" + + +async def test_stale_baseline_cannot_overwrite_or_partially_save_a_batch(identity_probe_db): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + issue_id = await seed(factory, Kind.ISSUE) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + async with factory.begin() as session: + with pytest.raises(MetadataBaselineConflictError): + await save_metadata_baselines( + session, + [ + MetadataBaselineWrite(issue_id, snapshot(Kind.ISSUE)), + MetadataBaselineWrite(local_id, snapshot(title="Stale")), + ], + ) + async with factory() as session: + assert await load_metadata_baseline(session, Kind.ISSUE, issue_id) is None + saved = await load_metadata_baseline(session, Kind.SERIES, local_id) + assert saved.snapshot.values.title == "Original" and saved.revision == 1 + + +@pytest.mark.parametrize("revision", [0, 1]) +async def test_concurrent_writers_have_one_winner(identity_probe_db, revision): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + if revision: + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + + async def write(title): + async with factory.begin() as session: + try: + return await save_metadata_baselines( + session, [MetadataBaselineWrite(local_id, snapshot(title=title), revision)] + ) + except MetadataBaselineConflictError: + return None + + results = await asyncio.wait_for(asyncio.gather(write("First"), write("Second")), 15) + assert results.count(None) == 1 + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.SERIES, local_id) + assert saved.revision == revision + 1 + assert (await session.get(Series, local_id)).path == "/reference/original" + + +@pytest.mark.parametrize("state", [State.STALE, State.CONFLICTED, None]) +async def test_missing_or_unverified_ownership_cannot_gain_a_managed_baseline( + identity_probe_db, state +): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + if state is None: + await session.execute(delete(SeriesExternalIdentity)) + else: + await session.execute(update(SeriesExternalIdentity).values(verification_state=state)) + async with factory.begin() as session: + with pytest.raises(MetadataBaselineConflictError): + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_parent_deletion_cascades_only_its_baseline(identity_probe_db, kind): + _, factory, _ = identity_probe_db + local_id = await seed(factory, kind) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot(kind))]) + async with factory.begin() as session: + model = {Kind.SERIES: Series, Kind.ISSUE: Issue, Kind.STORY_ARC: StoryArc}[kind] + await session.execute(delete(model).where(model.id == local_id)) + async with factory() as session: + assert await load_metadata_baseline(session, kind, local_id) is None + + +async def test_local_override_roundtrip_stays_protected_from_provider_refresh(identity_probe_db): + from pullbox.services.metadata_assembly import assemble_metadata + from tests.unit.test_metadata_discovery import row, runtime + + _, factory, _ = identity_probe_db + local_id = await seed(factory) + initial = snapshot() + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, initial)]) + async with factory.begin() as session: + saved = await load_metadata_baseline(session, Kind.SERIES, local_id) + assert saved is not None + changed = assemble_metadata( + Kind.SERIES, + initial.identities, + [row(Source.METRON_API, title="Provider")], + [runtime(Source.METRON_API).policy], + now=datetime.now(UTC), + current=MetadataValues(title="My title"), + previous=saved.snapshot, + replace_managed=True, + ) + await save_metadata_baselines( + session, [MetadataBaselineWrite(local_id, changed, saved.revision)] + ) + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.SERIES, local_id) + assert saved.snapshot.values.title == "My title" + assert next(item for item in saved.snapshot.origins if item.field == "title").user_override + + +async def test_baseline_migration_roundtrip_preserves_entities_and_matches_models( + identity_probe_db, +): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import Column, Integer, MetaData, Table, func + + from pullbox.models import Base + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + names = {f"{kind.value}_metadata_baselines" for kind in Kind} + async with engine.begin() as connection: + + def migrate(sync): + revision = _revision("t1n2o3p4q567_add_metadata_baselines", sync) + revision.downgrade() + revision.upgrade() + expected = MetaData() + for parent in ("series", "issues", "story_arcs"): + Table(parent, expected, Column("id", Integer, primary_key=True)) + for name in names: + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, name, type_, reflected, compare_to: ( + name in names if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + assert compare_metadata(context, expected) == [] + + await connection.run_sync(migrate) + async with factory() as session: + assert (await session.get(Series, local_id)).title == "Original" + assert await session.scalar(select(func.count()).select_from(SeriesExternalIdentity)) == 1 + assert await load_metadata_baseline(session, Kind.SERIES, local_id) is None + async with factory.begin() as session: + assert ( + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + )[0].revision == 1 + + +@pytest.mark.parametrize("kind", list(Kind)) +async def test_database_constraints_reject_orphan_duplicate_and_invalid_baselines( + identity_probe_db, kind +): + from sqlalchemy import insert + from sqlalchemy.exc import IntegrityError + + from pullbox.models import Base + + _, factory, _ = identity_probe_db + local_id = await seed(factory, kind) + table = Base.metadata.tables[f"{kind.value}_metadata_baselines"] + values = { + f"{kind.value}_id": local_id, + "revision": 1, + "snapshot_json": snapshot(kind).model_dump_json(), + } + async with factory.begin() as session: + await session.execute(insert(table).values(**values)) + for invalid in ({}, {f"{kind.value}_id": 99999}, {"revision": 0}, {"snapshot_json": ""}): + async with factory.begin() as session: + with pytest.raises(IntegrityError): + async with session.begin_nested(): + await session.execute( + update(table) + .where(table.c[f"{kind.value}_id"] == local_id) + .values(**invalid) + if invalid + else insert(table).values(**values) + ) + + +async def test_corrupt_saved_provenance_is_not_silently_treated_as_missing(identity_probe_db): + from pullbox.models import SeriesMetadataBaseline + + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, snapshot())]) + await session.execute( + update(SeriesMetadataBaseline).values(snapshot_json='{"schema_version":999}') + ) + async with factory() as session: + with pytest.raises(MetadataBaselineConflictError, match="invalid"): + await load_metadata_baseline(session, Kind.SERIES, local_id) + + +async def test_bulk_baseline_creation_uses_bounded_queries_not_per_issue_reads(identity_probe_db): + from sqlalchemy import event + + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + series = Series(title="Large", sort_title="Large") + session.add(series) + await session.flush() + issues = [Issue(series_id=series.id, issue_number=n) for n in range(1, 101)] + session.add_all(issues) + await session.flush() + session.add_all( + [ + IssueExternalIdentity( + issue_id=issue.id, + identity_namespace=Namespace.METRON, + external_id=str(n), + verification_state=State.VERIFIED, + evidence_kind="provider_result", + ) + for n, issue in enumerate(issues, 1) + ] + ) + writes = [ + MetadataBaselineWrite( + issue.id, + snapshot(Kind.ISSUE).model_copy( + update={ + "identities": (ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, str(n)),) + } + ), + ) + for n, issue in enumerate(issues, 1) + ] + statements = [] + + def capture(conn, cursor, statement, parameters, context, executemany): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", capture) + try: + async with factory.begin() as session: + assert len(await save_metadata_baselines(session, writes)) == 100 + finally: + event.remove(engine.sync_engine, "before_cursor_execute", capture) + assert len(statements) <= 12 diff --git a/tests/integration/metadata_identity/test_series_add_workflow.py b/tests/integration/metadata_identity/test_series_add_workflow.py index 7a77339d..8b3aeddc 100644 --- a/tests/integration/metadata_identity/test_series_add_workflow.py +++ b/tests/integration/metadata_identity/test_series_add_workflow.py @@ -14,6 +14,7 @@ from pullbox.models import Issue, Series from pullbox.models.config import SystemConfig from pullbox.models.library import LibraryRoot +from pullbox.models.metadata_baseline import IssueMetadataBaseline, SeriesMetadataBaseline from pullbox.models.metadata_identity import SeriesIdentityEvent from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import SeriesType @@ -194,7 +195,13 @@ def reject_commit(sync_session): assert list(publisher.iterdir()) == [marker] and marker.read_bytes() == b"untouched" assert not events async with factory() as session: - for model in (Series, Issue, SeriesIdentityEvent): + for model in ( + Series, + Issue, + SeriesIdentityEvent, + SeriesMetadataBaseline, + IssueMetadataBaseline, + ): assert await session.scalar(select(func.count()).select_from(model)) == 0 @@ -215,6 +222,33 @@ async def test_existing_owner_is_not_moved_or_remonitored(add_setup): assert result.series.title == "Operator title" and not result.series.monitored assert result.series.path == "/existing/reference/keep" assert len(events) == 1 and not list(root_path.iterdir()) + async with factory() as session: + baseline = await session.scalar(select(SeriesMetadataBaseline)) + assert baseline.revision == 1 + assert "Operator title" not in baseline.snapshot_json + + +async def test_final_classification_is_reflected_in_the_committed_baseline(add_setup): + from pullbox.core.metadata_identity import MetadataEntityKind + from pullbox.services.metadata_baselines import load_metadata_baseline + + factory, _, _, bus, _ = add_setup + data = titled_bundle() + for issue in data.issues: + issue.title = "Annual" + async with ( + factory() as session, + source_series_add_transaction( + session, data, library_root_id=None, search_on_add=False, event_bus=bus + ) as result, + ): + local_id = result.series.id + assert result.series.series_type is SeriesType.ANNUAL + assert result.snapshot.values.series_type == "annual" + async with factory() as session: + baseline = await load_metadata_baseline(session, MetadataEntityKind.SERIES, local_id) + assert baseline.snapshot == result.snapshot + assert baseline.snapshot.values.series_type == "annual" async def test_existing_unregistered_folders_are_preserved_with_native_suffix(add_setup): diff --git a/tests/integration/metadata_identity/test_series_adoption.py b/tests/integration/metadata_identity/test_series_adoption.py index 3130551e..643e91d3 100644 --- a/tests/integration/metadata_identity/test_series_adoption.py +++ b/tests/integration/metadata_identity/test_series_adoption.py @@ -449,3 +449,34 @@ async def test_explicit_provider_lifecycle_is_not_replaced_by_age_inference(iden async with factory.begin() as session: result = await adopt_source_series_bundle(session, data) assert result.series.status.value == "continuing" + + +async def test_add_persists_canonical_baselines_after_lifecycle_and_format_inference( + identity_probe_db, +): + from pullbox.services.metadata_baselines import load_metadata_baseline + + _, factory, _ = identity_probe_db + data = bundle(numbers=("1",)) + data.series.title = "Batman Annual" + data.series.sort_title = None + data.series.year_start = 2020 + data.issues[0].cover_date = date(2020, 1, 1) + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, data) + series_id = result.series.id + issue_id = (await session.scalar(select(Issue))).id + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.SERIES, series_id) + assert saved is not None + series = await session.get(Series, series_id) + assert saved.snapshot.values.series_type == series.series_type.value == "annual" + assert saved.snapshot.values.status == series.status.value == "ended" + assert saved.snapshot.values.year_end == series.year_end == 2020 + assert saved.snapshot.values.sort_title == series.sort_title + origins = {item.field: item for item in saved.snapshot.origins} + assert origins["status"].derivation == "lifecycle" + assert not origins["status"].user_override and origins["status"].source is None + assert origins["title"].source is Source.METRON_API + issue = await load_metadata_baseline(session, Kind.ISSUE, issue_id) + assert issue is not None and issue.snapshot.values.issue_number_text == "1" diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index ab330f3b..1322a1d9 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["s0m1n2o3p456"] + assert script.get_heads() == ["t1n2o3p4q567"] + assert script.get_revision("t1n2o3p4q567").down_revision == "s0m1n2o3p456" assert script.get_revision("s0m1n2o3p456").down_revision == "r9l0m1n2o345" assert script.get_revision("r9l0m1n2o345").down_revision == "q8k9l0m1n234" assert script.get_revision("q8k9l0m1n234").down_revision == "p7j8k9l0m123" diff --git a/tests/unit/test_metadata_assembly.py b/tests/unit/test_metadata_assembly.py index 8e365c01..5fecf6f4 100644 --- a/tests/unit/test_metadata_assembly.py +++ b/tests/unit/test_metadata_assembly.py @@ -15,7 +15,7 @@ from pullbox.core.metadata_identity import ( MetadataSource as Source, ) -from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues from pullbox.schemas.metadata_sources import MetadataDomain as Domain from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderStoryArcRead from pullbox.services.metadata_assembly import MetadataAssemblyError, assemble_metadata @@ -45,6 +45,53 @@ def origin(snapshot, field): return next(item for item in snapshot.origins if item.field == field) +def test_derived_values_are_refreshable_without_becoming_user_overrides(): + before = MetadataSnapshot( + entity_kind=Kind.SERIES, + identities=(identity(CV), identity(METRON)), + values=MetadataValues(status="ended"), + origins=( + FieldOrigin( + field="status", domain=Domain.CORE, observed_at=NOW, derivation="lifecycle" + ), + ), + ) + candidate = row(CV).model_copy(update={"status": "continuing"}) + result = assemble([candidate], previous=before, replace_managed=True) + assert result.values.status == "continuing" + assert origin(result, "status").source is CV + assert origin(result, "status").derivation is None + background = assemble([candidate], previous=before) + assert background.values.status == "ended" + assert origin(background, "status").derivation == "lifecycle" + edited = assemble( + [candidate], + previous=before, + current=MetadataValues(status="cancelled"), + replace_managed=True, + ) + assert edited.values.status == "cancelled" and origin(edited, "status").user_override + + +@pytest.mark.parametrize("attributes", [{"source": CV}, {"user_override": True}]) +def test_derived_provenance_cannot_claim_provider_or_user_origin(attributes): + with pytest.raises(ValidationError): + MetadataSnapshot( + entity_kind=Kind.SERIES, + identities=(identity(CV),), + values=MetadataValues(status="ended"), + origins=( + FieldOrigin( + field="status", + domain=Domain.CORE, + observed_at=NOW, + derivation="lifecycle", + **attributes, + ), + ), + ) + + def test_domain_authority_gap_fill_and_serializable_provenance(): cv = row(CV, title="Primary title").model_copy( update={"image_url": "https://comicvine.gamespot.com/a.jpg", "source_updated_at": OLDER} diff --git a/tests/unit/test_metadata_refresh_snapshot.py b/tests/unit/test_metadata_refresh_snapshot.py index e22ee8fc..df4b64ac 100644 --- a/tests/unit/test_metadata_refresh_snapshot.py +++ b/tests/unit/test_metadata_refresh_snapshot.py @@ -87,6 +87,29 @@ async def test_failure_keeps_typed_outcome_and_falls_back_without_changing_user_ assert result.snapshot.values.title == "Saved" +async def test_explicit_refresh_requests_replacements_for_inferred_metadata(): + from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues + + registry, adapters = setup() + before = MetadataSnapshot( + entity_kind=Kind.SERIES, + identities=(identity(CV), identity(METRON)), + values=MetadataValues(status="ended"), + origins=( + FieldOrigin( + field="status", domain=Domain.CORE, observed_at=NOW, derivation="lifecycle" + ), + ), + ) + adapters[CV].result.data.status = "continuing" + result = await fetch( + registry, fields=frozenset({"status"}), previous=before, replace_managed=True + ) + assert result.snapshot.values.status == "continuing" + assert adapters[CV].calls == [("series", "42", None)] + assert not adapters[METRON].calls + + async def test_background_already_populated_or_protected_fields_need_no_network(): registry, adapters = setup() before = assemble([row(CV, title="Saved")]) From 11f013ffc805775e781831ff153746ee2d72ca22 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 13:34:18 -0700 Subject: [PATCH 44/97] feat(metadata): refresh series through configured sources Use revision-checked canonical snapshots and complete provider catalogs for manual refresh while preserving local edits, issue identities, and owned files. Release read transactions before provider requests and publish covers after the metadata commit. Checkpoint: service and API coverage is in place; the browser refresh/error-message test remains incomplete and requires follow-up before merge. --- src/pullbox/api/v1/series.py | 16 +- .../services/metadata_refresh_snapshot.py | 6 +- .../services/metadata_series_adoption.py | 51 +- .../services/metadata_series_refresh.py | 446 ++++++++++++++ .../services/metadata_series_refresh_state.py | 229 +++++++ tests/e2e/test_source_series_add.py | 20 +- .../metadata_identity/test_series_refresh.py | 576 ++++++++++++++++++ tests/unit/test_series_api_branches.py | 26 +- 8 files changed, 1343 insertions(+), 27 deletions(-) create mode 100644 src/pullbox/services/metadata_series_refresh.py create mode 100644 src/pullbox/services/metadata_series_refresh_state.py create mode 100644 tests/integration/metadata_identity/test_series_refresh.py diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py index 293eb7e6..d22dce1b 100644 --- a/src/pullbox/api/v1/series.py +++ b/src/pullbox/api/v1/series.py @@ -606,7 +606,12 @@ async def refresh_series( _user: AuthenticatedUser, session: DbSession, ) -> SeriesResponse: - """Refresh series metadata from ComicVine.""" + """Refresh metadata and the issue catalog from configured, verified sources.""" + from pullbox.services.metadata_series_refresh import ( + SeriesRefreshError, + source_series_refresh_transaction, + ) + series = await session.get(Series, series_id) if series is None: raise NotFoundError("Series", series_id) @@ -615,9 +620,12 @@ async def refresh_series( status_code=409, detail="Initial metadata sync is already in progress.", ) - metadata_svc = await _build_metadata_service(session) - await metadata_svc.refresh_series(session, series_id, force=True) - return await _load_series_response(session, series_id) + try: + async with source_series_refresh_transaction(session, series_id): + response = await _load_series_response(session, series_id) + return response + except SeriesRefreshError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc @router.post("/{series_id}/rename-folder", status_code=200) diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py index 6ea76054..90977ec3 100644 --- a/src/pullbox/services/metadata_refresh_snapshot.py +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -29,6 +29,7 @@ class MetadataRefreshSnapshot(BaseModel): snapshot: MetadataSnapshot outcomes: tuple[SourceOutcome, ...] revisions: dict[MetadataSource, int] + candidates: tuple[MetadataCandidate, ...] = () async def fetch_metadata_snapshot( @@ -174,5 +175,8 @@ def rank(source: MetadataSource, field: str) -> tuple[int, str]: candidates.append(result.data) snapshot = assemble(local=True) return MetadataRefreshSnapshot( - snapshot=snapshot, outcomes=tuple(outcomes.values()), revisions=revisions + snapshot=snapshot, + outcomes=tuple(outcomes.values()), + revisions=revisions, + candidates=tuple(candidates), ) diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index a00daa0e..e000f77f 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -65,6 +65,17 @@ def _metadata_label(source: MetadataSource) -> str: class SeriesAdoptionError(ValueError): """A complete, consistent source catalog could not be adopted safely.""" + def __init__( + self, + message: str, + *, + status: SourceStatus | None = None, + retry_after_seconds: int | None = None, + ) -> None: + super().__init__(message) + self.status = status + self.retry_after_seconds = retry_after_seconds + @dataclass(frozen=True) class SourceSeriesBundle: @@ -90,6 +101,7 @@ async def fetch_source_series_bundle( source_revision: int, max_issues: int = 10000, timeout: float = 120, + profile: ProviderSeriesRead | None = None, ) -> SourceSeriesBundle: identifier = source_id(source, MetadataEntityKind.SERIES, external_id) runtime = registry.runtime.get(source) @@ -99,10 +111,24 @@ async def fetch_source_series_bundle( raise ValueError("Invalid adoption resource limits") try: async with asyncio.timeout(timeout): - profile = await registry.series(source, identifier) - if profile.status is not SourceStatus.OK or profile.data is None: - raise SeriesAdoptionError(f"Could not read the series: {profile.status.value}.") - if profile.data.issue_count is not None and profile.data.issue_count > max_issues: + if profile is None: + result_profile = await registry.series(source, identifier) + if result_profile.status is not SourceStatus.OK or result_profile.data is None: + raise SeriesAdoptionError( + f"Could not read the series: {result_profile.status.value}.", + status=result_profile.status, + retry_after_seconds=result_profile.retry_after_seconds, + ) + profile = result_profile.data + if ( + profile.source is not source + or profile.identity_namespace is not source.identity_namespace + or profile.external_id != identifier + ): + raise SeriesAdoptionError( + "The series profile belongs to a different source identity." + ) + if profile.issue_count is not None and profile.issue_count > max_issues: raise SeriesAdoptionError("The issue catalog exceeds the interactive add limit.") issues: list[ProviderIssueRead] = [] seen: set[str] = set() @@ -112,13 +138,16 @@ async def fetch_source_series_bundle( result = await registry.issues(source, identifier, page=page) if result.status is not SourceStatus.OK or result.data is None: raise SeriesAdoptionError( - f"Could not finish the issue catalog: {result.status.value}. Retry the add." + f"Could not finish the issue catalog: {result.status.value}. " + "Retry the add.", + status=result.status, + retry_after_seconds=result.retry_after_seconds, ) current = result.data if total is None: total = current.total if current.total != total or ( - profile.data.issue_count is not None and profile.data.issue_count != total + profile.issue_count is not None and profile.issue_count != total ): raise SeriesAdoptionError("The provider catalog count changed. Retry the add.") if total > max_issues: @@ -127,8 +156,8 @@ async def fetch_source_series_bundle( ) for issue in current.results: if source is MetadataSource.COMICVINE_LOCAL and ( - profile.data.source_updated_at is None - or issue.source_updated_at != profile.data.source_updated_at + profile.source_updated_at is None + or issue.source_updated_at != profile.source_updated_at ): raise SeriesAdoptionError("The local catalog changed. Retry the add.") if issue.external_id in seen: @@ -140,10 +169,12 @@ async def fetch_source_series_bundle( if current.next_page is None: if len(issues) != total: raise SeriesAdoptionError("The issue catalog is incomplete. Retry the add.") - return SourceSeriesBundle(profile.data, tuple(issues), source_revision, total) + return SourceSeriesBundle(profile, tuple(issues), source_revision, total) page = current.next_page except TimeoutError as exc: - raise SeriesAdoptionError("The issue catalog request timed out. Retry the add.") from exc + raise SeriesAdoptionError( + "The issue catalog request timed out. Retry the add.", status=SourceStatus.TIMEOUT + ) from exc async def adopt_source_series_bundle( diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py new file mode 100644 index 00000000..e34dd18f --- /dev/null +++ b/src/pullbox/services/metadata_series_refresh.py @@ -0,0 +1,446 @@ +"""Source-aware series refresh: short read, bounded fetch, revision-checked write.""" + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path + +import structlog +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.config import get_settings +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, field_domain +from pullbox.schemas.metadata_sources import ( + MetadataDomain, + ProviderSeriesRead, + SourceCapability, + SourceStatus, +) +from pullbox.services.cover_resolver import resolve_covers_dir +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_identity_attachment import attach_verified_identities +from pullbox.services.metadata_identity_review import record_identity_observation +from pullbox.services.metadata_read_cache import source_read_cache +from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot +from pullbox.services.metadata_series_adoption import ( + SeriesAdoptionError, + SourceSeriesBundle, + _identities, + _metadata_label, + _request, + _require_unowned_issues, + _validate_bundle, + fetch_source_series_bundle, +) +from pullbox.services.metadata_series_refresh_state import ( + ISSUE_FIELDS, + SERIES_FIELDS, + SeriesRefreshState, + read_series_refresh_state, +) +from pullbox.services.metadata_service import MetadataService, classify_issue_metadata +from pullbox.services.metadata_sources import load_source_runtime +from pullbox.services.metadata_writer_identity import metadata_write_scope +from pullbox.services.provider_artwork import ProviderArtworkClient, pending_provider_cover + +logger = structlog.get_logger(__name__) + + +class SeriesRefreshError(ValueError): + """The refresh requires a new read or an explicit identity decision.""" + + +@asynccontextmanager +async def source_series_refresh_transaction( + session: AsyncSession, series_id: int +) -> AsyncIterator[Series]: + """Serialize the response before commit, then refresh artwork outside the write.""" + if session.new or session.dirty or session.deleted: + raise SeriesRefreshError("Finish pending library changes before refreshing metadata.") + covers = await resolve_covers_dir(session) + try: + series = await refresh_series_from_sources(session, series_id) + cover_url = series.cover_url + yield series + await session.commit() + except BaseException: + await session.rollback() + raise + if cover_url: + await _refresh_artwork(session, series_id, cover_url, covers) + + +async def _refresh_artwork(session: AsyncSession, series_id: int, url: str, covers: Path) -> None: + destination = covers / str(series_id) / "series.jpg" + async with ( + ProviderArtworkClient() as client, + pending_provider_cover(client, url, destination) as pending, + ): + if pending is None: + return + async with session.begin(): + # A different refresh/delete may have won while the image downloaded. + current = await session.scalar( + select(Series) + .where(Series.id == series_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + if current is not None and current.cover_url == url: + pending.replace(destination) + current.cover_path = f"/api/v1/series/{series_id}/cover" + + +async def refresh_series_from_sources( + session: AsyncSession, + series_id: int, + *, + registry: MetadataSourceRegistry | None = None, + replace_managed: bool = True, +) -> Series: + """Release only a clean read transaction; leave the atomic write to the caller. + + No library/archive files are modified. The caller owns response construction, + commit, and post-commit cover work. Never call from a pending edit transaction. + """ + if session.new or session.dirty or session.deleted: + raise SeriesRefreshError("Finish pending library changes before refreshing metadata.") + try: + before = await read_series_refresh_state(session, series_id) + if not before.series.identities: + raise SeriesRefreshError( + "This series needs a verified metadata identity before refresh. Review its match." + ) + if registry is None: + enabled = get_settings().metadata_gcd_api_v2_enabled + runtime = await load_source_runtime(session, gcd_api_enabled=enabled) + registry = MetadataSourceRegistry( + runtime, + gcd_api_enabled=enabled, + read_cache=source_read_cache(session), + revalidate_reads=True, + total_timeout=60, + ) + else: + registry = MetadataSourceRegistry( + list(registry.runtime.values()), + factories=registry.factories, + gcd_api_enabled=registry.gcd_api_enabled, + read_cache=registry.read_cache, + revalidate_reads=True, + per_source_timeout=registry.per_source_timeout, + total_timeout=registry.total_timeout, + concurrency=registry.concurrency, + ) + await session.rollback() + now = datetime.now(UTC) + async with asyncio.timeout(120): + fetched = await fetch_metadata_snapshot( + registry, + MetadataEntityKind.SERIES, + before.series.identities, + now=now, + requested_fields=SERIES_FIELDS, + current=before.series.values, + previous=before.series.baseline, + overrides=before.series.overrides, + replace_managed=replace_managed, + ) + profiles = { + item.source: item + for item in fetched.candidates + if isinstance(item, ProviderSeriesRead) + } + failed = { + item.source + for item in fetched.outcomes + if item.status not in {SourceStatus.OK, SourceStatus.NOT_QUERIED} + } + bundle = await _catalog(registry, before, profiles, failed) + profiles[bundle.series.source] = bundle.series + snapshot = assemble_metadata( + MetadataEntityKind.SERIES, + before.series.identities, + list(profiles.values()), + before.policies, + now=now, + current=before.series.values, + previous=before.series.baseline, + overrides=before.series.overrides, + replace_managed=replace_managed, + fields=SERIES_FIELDS, + ) + async with metadata_write_scope(session): + # Source policy writers never take entity locks. Lock policies first, + # followed by the same parent-first graph order as identity writers. + await session.execute( + select(MetadataSourceConfig) + .order_by(MetadataSourceConfig.source) + .with_for_update(read=True) + ) + await session.execute(select(Series.id).where(Series.id == series_id).with_for_update()) + await session.execute( + select(Issue.id) + .where(Issue.series_id == series_id) + .order_by(Issue.id) + .with_for_update() + ) + current = await read_series_refresh_state(session, series_id) + if current != before: + raise SeriesRefreshError( + "Library metadata, identities or source settings changed. Retry the refresh." + ) + series = await _apply( + session, current, bundle, snapshot, now, replace_managed=replace_managed + ) + await session.flush() + logger.info( + "metadata_series_source_refreshed", + series_id=series_id, + source=bundle.series.source.value, + catalog_count=bundle.catalog_total, + ) + return series + except SeriesRefreshError: + raise + except (ValueError, IntegrityError, ValidationError) as exc: + raise SeriesRefreshError( + "Metadata could not be refreshed safely. Review the series match " + "or retry after source settings are corrected." + ) from exc + except TimeoutError as exc: + raise SeriesRefreshError( + "Metadata refresh timed out. No library metadata was changed; retry later." + ) from exc + + +async def _catalog( + registry: MetadataSourceRegistry, + state: SeriesRefreshState, + profiles: dict[MetadataSource, ProviderSeriesRead], + failed: set[MetadataSource], +) -> SourceSeriesBundle: + known = {item.namespace: item.external_id for item in state.series.identities} + sources = sorted( + registry.runtime, + key=lambda source: ( + registry.runtime[source].policy.domain_priorities.get( + MetadataDomain.ISSUES, registry.runtime[source].policy.priority + ), + source.value, + ), + ) + for source in sources: + if ( + source in failed + or source.identity_namespace not in known + or registry._unavailable(source, capability=SourceCapability.ISSUE_LIST) + ): + continue + try: + bundle = await fetch_source_series_bundle( + registry, + source, + known[source.identity_namespace], + source_revision=registry.runtime[source].policy.revision, + profile=profiles.get(source), + ) + _validate_bundle(bundle) + return bundle + except SeriesAdoptionError as exc: + if exc.status is None or exc.status is SourceStatus.INCOMPATIBLE_RESPONSE: + raise SeriesRefreshError( + "The provider issue catalog changed or disagrees with this series. " + "Review the issue match before retrying." + ) from exc + logger.info( + "metadata_series_catalog_unavailable", source=source.value, status=exc.status.value + ) + raise SeriesRefreshError( + "No configured source could supply a complete issue catalog. Check source status and retry." + ) + + +def _with_derived( + snapshot: MetadataSnapshot, field: str, value: object, now: datetime +) -> MetadataSnapshot: + if getattr(snapshot.values, field) == value: + return snapshot + origins = {item.field: item for item in snapshot.origins} + origins[field] = FieldOrigin( + field=field, + domain=field_domain(snapshot.entity_kind, field), + observed_at=now, + derivation="catalog" if field == "issue_count" else "lifecycle", + ) + return MetadataSnapshot.model_validate( + { + **snapshot.model_dump(), + "values": {**snapshot.values.model_dump(), field: value}, + "origins": tuple(origins.values()), + } + ) + + +async def _apply( + session: AsyncSession, + state: SeriesRefreshState, + bundle: SourceSeriesBundle, + snapshot: MetadataSnapshot, + now: datetime, + *, + replace_managed: bool, +) -> Series: + series = await session.get(Series, state.series.local_id) + assert series is not None + source = bundle.series.source + by_identity = {identity: item for item in state.issues for identity in item.identities} + numbers = _validate_bundle(bundle) + offered = { + ExternalIdentityRef(source.identity_namespace, MetadataEntityKind.ISSUE, item.external_id) + for item in bundle.issues + } + missing = { + identity for identity in by_identity if identity.namespace is source.identity_namespace + } - offered + if missing: + raise SeriesRefreshError( + "The provider removed existing issue identities. " + "Review the catalog; existing issues and files were kept." + ) + by_number = {item.values.issue_number_text: item for item in state.issues} + prepared = [] + for metadata, (number, text) in zip(bundle.issues, numbers, strict=True): + identity = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id + ) + existing = by_identity.get(identity) + if existing is not None and existing.values.issue_number_text != text: + raise SeriesRefreshError( + "A provider issue was renumbered. Review the issue match; existing files were kept." + ) + if existing is None and text in by_number: + raise SeriesRefreshError( + "An issue designation already belongs to a different identity. " + "Review its match; no issues were reassigned." + ) + issue_snapshot = assemble_metadata( + MetadataEntityKind.ISSUE, + existing.identities if existing else (identity,), + [metadata.model_copy(update={"issue_number_text": text})], + state.policies, + now=now, + current=existing.values if existing else None, + previous=existing.baseline if existing else None, + parent_identities=state.series.identities, + replace_managed=replace_managed, + fields=ISSUE_FIELDS, + ) + prepared.append((existing, metadata, number, text, issue_snapshot)) + values = snapshot.values + new_metadata = tuple(metadata for old, metadata, *_rest in prepared if old is None) + if new_metadata: + await _require_unowned_issues( + session, + SourceSeriesBundle( + bundle.series, new_metadata, bundle.source_revision, len(new_metadata) + ), + ) + existing_issues = { + item.id: item + for item in await session.scalars(select(Issue).where(Issue.series_id == series.id)) + } + series.title = values.title or series.title + series.sort_title = values.sort_title or series.sort_title + series.description, series.year_start, series.year_end = ( + values.description, + values.year_start, + values.year_end, + ) + series.cover_url = values.image_url + series.publisher_id = ( + await MetadataService._ensure_publisher(session, values.publisher) + if values.publisher + else None + ) + if values.series_type is not None: + series.series_type = SeriesType(values.series_type) + if values.status is not None: + series.status = SeriesStatus(values.status) + additions = 0 + for offset in range(0, len(prepared), 200): + pending = [] + for existing, metadata, number, text, issue_snapshot in prepared[offset : offset + 200]: + issue = existing_issues.get(existing.local_id) if existing else None + if issue is None: + issue = Issue( + series_id=series.id, + issue_number=number, + issue_number_text=text, + status=IssueStatus.WANTED if series.monitored else IssueStatus.SKIPPED, + issue_type=classify_issue_metadata(series.series_type, metadata.title)[1], + metadata_source=_metadata_label(source), + ) + session.add(issue) + additions += 1 + value = issue_snapshot.values + issue.title, issue.description = value.title, value.description + issue.release_date, issue.store_date = value.cover_date, value.store_date + issue.page_count, issue.cover_url = value.page_count, value.image_url + pending.append((issue, existing, metadata, issue_snapshot)) + await session.flush() + await attach_verified_identities( + session, + [ + _request( + bundle, metadata, _identities(metadata, MetadataEntityKind.ISSUE)[0], issue.id + ) + for issue, old, metadata, _ in pending + if old is None + ], + ) + for issue, old, metadata, _ in pending: + if old is None: + for crosswalk in _identities(metadata, MetadataEntityKind.ISSUE)[1:]: + await record_identity_observation( + session, _request(bundle, metadata, crosswalk, issue.id, observation=True) + ) + await save_metadata_baselines( + session, + [ + MetadataBaselineWrite(issue.id, item, old.baseline_revision if old else 0) + for issue, old, _, item in pending + ], + ) + series.issue_count = len(state.issues) + additions + snapshot = _with_derived(snapshot, "issue_count", series.issue_count, now) + origins = {item.field: item for item in snapshot.origins} + status_origin = origins.get("status") + if ( + status_origin is not None + and status_origin.derivation == "lifecycle" + and not series.status_override + ): + await MetadataService.infer_series_status(session, series) + snapshot = _with_derived(snapshot, "status", series.status.value, now) + snapshot = _with_derived(snapshot, "year_end", series.year_end, now) + series.metadata_last_refreshed = now + series.issue_catalog_state = IssueCatalogState.COMPLETE + series.issue_catalog_last_synced_at = now + series.issue_catalog_last_checked_at = now + series.issue_catalog_error = None + await save_metadata_baselines( + session, [MetadataBaselineWrite(series.id, snapshot, state.series.baseline_revision)] + ) + return series diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py new file mode 100644 index 00000000..225b606a --- /dev/null +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -0,0 +1,229 @@ +"""Bounded, immutable library read set for source-aware refresh.""" + +from dataclasses import dataclass + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import NotFoundError +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Issue, Series +from pullbox.models.metadata_baseline import IssueMetadataBaseline, SeriesMetadataBaseline +from pullbox.models.metadata_identity import ( + IssueExternalIdentity, + IssueIdentityEvent, + SeriesExternalIdentity, + SeriesIdentityEvent, +) +from pullbox.models.publisher import Publisher +from pullbox.models.series import IssueCatalogState +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import SourcePolicyRead +from pullbox.services.metadata_baselines import MetadataBaselineConflictError +from pullbox.services.metadata_sources import read_source_policies + +SERIES_FIELDS = frozenset( + { + "title", + "sort_title", + "publisher", + "description", + "year_start", + "year_end", + "series_type", + "status", + "issue_count", + "image_url", + "volume", + "language", + } +) +ISSUE_FIELDS = frozenset( + { + "title", + "description", + "issue_number_text", + "cover_date", + "store_date", + "page_count", + "image_url", + } +) + + +@dataclass(frozen=True) +class RefreshEntityState: + local_id: int + values: MetadataValues + baseline: MetadataSnapshot | None + baseline_revision: int + claims: tuple[tuple[ExternalIdentityRef, IdentityVerificationState, int], ...] + event_id: int + comicvine_id: int | None + overrides: frozenset[str] = frozenset() + + @property + def identities(self) -> tuple[ExternalIdentityRef, ...]: + return tuple( + ref for ref, state, _ in self.claims if state is IdentityVerificationState.VERIFIED + ) + + +@dataclass(frozen=True) +class SeriesRefreshState: + series: RefreshEntityState + issues: tuple[RefreshEntityState, ...] + policies: tuple[SourcePolicyRead, ...] + + +def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot | None: + if payload is None: + return None + try: + snapshot = MetadataSnapshot.model_validate_json(payload) + if snapshot.entity_kind is not kind: + raise ValueError("Wrong baseline kind") + return snapshot + except ValueError as exc: + raise MetadataBaselineConflictError( + "Stored metadata baseline is invalid. Review before refreshing." + ) from exc + + +async def read_series_refresh_state(session: AsyncSession, series_id: int) -> SeriesRefreshState: + row = ( + await session.execute( + select( + Series, + Publisher.name, + SeriesMetadataBaseline.revision, + SeriesMetadataBaseline.snapshot_json, + ) + .outerjoin(Publisher, Publisher.id == Series.publisher_id) + .outerjoin(SeriesMetadataBaseline, SeriesMetadataBaseline.series_id == Series.id) + .where(Series.id == series_id) + .execution_options(populate_existing=True) + ) + ).one_or_none() + if row is None: + raise NotFoundError("Series", series_id) + series, publisher, revision, payload = row + if series.issue_catalog_state is IssueCatalogState.HYDRATING: + raise ValueError("Initial metadata sync is already in progress.") + baseline = _baseline(payload, MetadataEntityKind.SERIES) + values = baseline.values.model_dump() if baseline else {} + values.update( + title=series.title, + sort_title=series.sort_title, + publisher=publisher, + description=series.description, + year_start=series.year_start, + year_end=series.year_end, + series_type=series.series_type.value, + status=series.status.value, + issue_count=series.issue_count, + image_url=series.cover_url, + ) + claims = tuple( + ( + ExternalIdentityRef( + item.identity_namespace, MetadataEntityKind.SERIES, item.external_id + ), + item.verification_state, + item.revision, + ) + for item in await session.scalars( + select(SeriesExternalIdentity) + .where(SeriesExternalIdentity.series_id == series_id) + .order_by(SeriesExternalIdentity.identity_namespace) + .execution_options(populate_existing=True) + ) + ) + event_id = await session.scalar( + select(func.max(SeriesIdentityEvent.id)).where(SeriesIdentityEvent.series_id == series_id) + ) + parent = RefreshEntityState( + series_id, + MetadataValues.model_validate(values), + baseline, + revision or 0, + claims, + event_id or 0, + series.comicvine_id, + frozenset({"status", "year_end"}) if series.status_override else frozenset(), + ) + members = ( + await session.execute( + select(Issue, IssueMetadataBaseline.revision, IssueMetadataBaseline.snapshot_json) + .outerjoin(IssueMetadataBaseline, IssueMetadataBaseline.issue_id == Issue.id) + .where(Issue.series_id == series_id) + .order_by(Issue.id) + .limit(10001) + .execution_options(populate_existing=True) + ) + ).all() + if len(members) > 10000: + raise ValueError("This series exceeds the bounded refresh limit of 10,000 issues.") + issue_claims: dict[int, list[tuple[ExternalIdentityRef, IdentityVerificationState, int]]] = {} + for claim in await session.scalars( + select(IssueExternalIdentity) + .join(Issue, Issue.id == IssueExternalIdentity.issue_id) + .where(Issue.series_id == series_id) + .order_by(IssueExternalIdentity.identity_namespace) + .execution_options(populate_existing=True) + ): + issue_claims.setdefault(claim.issue_id, []).append( + ( + ExternalIdentityRef( + claim.identity_namespace, MetadataEntityKind.ISSUE, claim.external_id + ), + claim.verification_state, + claim.revision, + ) + ) + events = dict( + ( + await session.execute( + select(IssueIdentityEvent.issue_id, func.max(IssueIdentityEvent.id)) + .join(Issue, Issue.id == IssueIdentityEvent.issue_id) + .where(Issue.series_id == series_id) + .group_by(IssueIdentityEvent.issue_id) + ) + ) + .tuples() + .all() + ) + issues = [] + for issue, issue_revision, issue_payload in members: + previous = _baseline(issue_payload, MetadataEntityKind.ISSUE) + issue_values = previous.values.model_dump() if previous else {} + issue_values.update( + title=issue.title, + description=issue.description, + issue_number_text=issue.effective_issue_number_text, + cover_date=issue.release_date, + store_date=issue.store_date, + page_count=issue.page_count, + image_url=issue.cover_url, + ) + issues.append( + RefreshEntityState( + issue.id, + MetadataValues.model_validate(issue_values), + previous, + issue_revision or 0, + tuple(issue_claims.get(issue.id, ())), + events.get(issue.id, 0), + issue.comicvine_id, + ) + ) + # Connection-test timestamps do not affect authority. Everything that can + # affect provider selection remains part of the read set, including revision. + policies = tuple( + item.model_copy( + update={"last_tested_at": None, "last_success_at": None, "last_status": None} + ) + for item in await read_source_policies(session) + ) + return SeriesRefreshState(parent, tuple(issues), policies) diff --git a/tests/e2e/test_source_series_add.py b/tests/e2e/test_source_series_add.py index 7d7e6db0..7a844e57 100644 --- a/tests/e2e/test_source_series_add.py +++ b/tests/e2e/test_source_series_add.py @@ -199,12 +199,13 @@ def respond(route): assert len(previews) == 2 -def test_real_local_search_preview_add_and_existing_owner( +def test_real_local_search_preview_add_refresh_and_existing_owner( authed_page, seeded_server, monkeypatch, tmp_path ): from pullbox.api.v1 import series as series_api from pullbox.providers.metadata import sources from pullbox.services.catalog import reader as catalog_reader + from pullbox.services.provider_artwork import ProviderArtworkClient from tests.unit.test_catalog_reader import installed_reader reader = installed_reader(tmp_path) @@ -213,6 +214,7 @@ def test_real_local_search_preview_add_and_existing_owner( # This test owns the browser/database/folder workflow, not scheduled downloads. events = AsyncMock() monkeypatch.setattr(series_api, "get_event_bus", lambda: events) + monkeypatch.setattr(ProviderArtworkClient, "download_cover", AsyncMock(return_value=False)) page = authed_page page.goto(f"{seeded_server}/series/add?q=Dark+Knight") trigger = page.locator('[data-add-series-trigger="true"]').first @@ -239,6 +241,22 @@ def test_real_local_search_preview_add_and_existing_owner( "href", f"/series/{record['id']}" ) assert events.emit.await_count == 1 + page.goto(f"{seeded_server}/series/{record['id']}") + with page.expect_response( + lambda response: response.url.endswith(f"/series/{record['id']}/refresh") + ) as refreshed: + page.get_by_test_id("series-action-refresh").click() + assert refreshed.value.status == 200, refreshed.value.text() + assert refreshed.value.json()["issue_catalog_state"] == "complete" + expect(page.get_by_test_id("series-action-refresh")).to_be_enabled() + detail = "A provider issue was renumbered. Review the issue match; existing files were kept." + page.route( + f"**/api/v1/series/{record['id']}/refresh", + lambda route: route.fulfill(status=409, json={"detail": detail}), + ) + page.get_by_test_id("series-action-refresh").click() + expect(page.get_by_text(detail, exact=True)).to_be_visible() + expect(page.get_by_test_id("series-action-refresh")).to_be_enabled() @pytest.mark.parametrize("theme,width", [("light", 1280), ("dark", 1280), ("light", 320)]) diff --git a/tests/integration/metadata_identity/test_series_refresh.py b/tests/integration/metadata_identity/test_series_refresh.py new file mode 100644 index 00000000..3e2a8b69 --- /dev/null +++ b/tests/integration/metadata_identity/test_series_refresh.py @@ -0,0 +1,576 @@ +"""Actual refresh must preserve the library while provider requests are in flight.""" + +import asyncio +from dataclasses import replace + +import pytest +from sqlalchemy import func, select, update + +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_baseline import SeriesMetadataBaseline +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState, SeriesStatusOverride +from pullbox.schemas.metadata_sources import MetadataFetch, MetadataPage, SourceStatus +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_series_adoption import adopt_source_series_bundle +from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_from_sources +from tests.integration.metadata_identity.test_series_adoption import ( # noqa: F401 + bundle, + configured_sources, +) +from tests.unit.test_metadata_source_reads import ReadAdapter, registry + + +class RefreshAdapter(ReadAdapter): + def __init__(self, data, *, wait=None, session=None): + super().__init__(wait=wait) + self.data = data + self.session = session + self.failure = None + self.profile_failure = None + + async def series(self, external_id, *, validator=None): + assert self.session is None or not self.session.in_transaction() + self.calls.append(("series", external_id)) + self.started.set() + if self.wait: + await self.wait.wait() + if self.profile_failure: + return MetadataFetch(status=self.profile_failure) + return MetadataFetch(status=SourceStatus.OK, data=self.data.series) + + async def issues(self, external_id, *, page=1, validator=None): + assert self.session is None or not self.session.in_transaction() + self.calls.append(("issues", external_id, page)) + if self.failure: + return MetadataFetch(status=self.failure) + start = (page - 1) * 100 + end = min(start + 100, len(self.data.issues)) + return MetadataFetch( + status=SourceStatus.OK, + data=MetadataPage( + results=list(self.data.issues[start:end]), + total=len(self.data.issues), + next_page=page + 1 if end < len(self.data.issues) else None, + ), + ) + + +def refresh_registry(adapter): + result = registry(adapter, total_timeout=60) + result.runtime[Source.METRON_API].policy.revision = 1 + return result + + +async def seed(factory, *, count=1): + data = bundle(numbers=tuple(str(i + 1) for i in range(count))) + data.series.description = "Original provider description" + data.issues[0].title = "Original issue title" + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, data, monitored=True) + result.series.path = "/reference/original" + series_id = result.series.id + issue = await session.scalar(select(Issue).order_by(Issue.id)) + issue.status = IssueStatus.OWNED + issue.manual_skip = True + issue_id = issue.id + return series_id, issue_id, data + + +async def test_native_refresh_updates_managed_values_and_adds_complete_lettered_catalog( + identity_probe_db, +): + _, factory, _ = identity_probe_db + series_id, issue_id, _ = await seed(factory) + data = bundle(numbers=("1", "50-x", "50-o")) + data.series.description = "Updated provider description" + data.issues[0].title = "Updated issue title" + async with factory() as session: + adapter = RefreshAdapter(data, session=session) + result = await refresh_series_from_sources( + session, series_id, registry=refresh_registry(adapter) + ) + assert result is not None, "The ordinary refresh must support a native Metron series" + assert result.description == "Updated provider description" + assert result.comicvine_id is None and result.path == "/reference/original" + assert result.issue_count == 3 and result.issue_catalog_state is IssueCatalogState.COMPLETE + await session.commit() + async with factory() as session: + issues = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert [item.issue_number_text for item in issues] == ["1", "50-X", "50-O"] + assert issues[0].id == issue_id and issues[0].status is IssueStatus.OWNED + assert issues[0].manual_skip and issues[0].title == "Updated issue title" + assert all(item.status is IssueStatus.WANTED for item in issues[1:]) + saved = await load_metadata_baseline(session, Kind.SERIES, series_id) + assert saved.revision == 2 and saved.snapshot.values.description == result.description + assert (await load_metadata_baseline(session, Kind.ISSUE, issue_id)).revision == 2 + assert adapter.calls == [("series", "42"), ("issues", "42", 1)] + + +async def test_refresh_preserves_local_edits_and_explicit_status_override(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + async with factory.begin() as session: + series = await session.get(Series, series_id) + series.description = "My description" + series.status_override = SeriesStatusOverride.ENDED + series.status = "ended" + issue = await session.get(Issue, issue_id) + issue.title = None + data.series.description = "Provider replacement" + data.series.status = "continuing" + data.issues[0].title = "Provider issue replacement" + async with factory() as session: + result = await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + assert result is not None + await session.commit() + async with factory() as session: + assert (await session.get(Series, series_id)).description == "My description" + assert (await session.get(Series, series_id)).status.value == "ended" + assert (await session.get(Issue, issue_id)).title is None + saved = await load_metadata_baseline(session, Kind.ISSUE, issue_id) + assert next( + origin for origin in saved.snapshot.origins if origin.field == "title" + ).user_override + + +@pytest.mark.parametrize("change", ["series_edit", "issue_edit", "identity", "policy", "baseline"]) +async def test_inflight_change_rejects_whole_refresh_without_losing_newer_state( + identity_probe_db, change +): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + data.series.description = "Late provider data" + adapter = RefreshAdapter(data, wait=asyncio.Event()) + async with factory() as session: + task = asyncio.create_task( + refresh_series_from_sources(session, series_id, registry=refresh_registry(adapter)) + ) + started = asyncio.create_task(adapter.started.wait()) + done, _ = await asyncio.wait( + [task, started], timeout=5, return_when=asyncio.FIRST_COMPLETED + ) + if started not in done: + started.cancel() + await asyncio.gather(started, return_exceptions=True) + assert not task.done(), "Refresh did not reach its bounded provider read" + try: + async with factory.begin() as editor: + if change == "series_edit": + await editor.execute( + update(Series) + .where(Series.id == series_id) + .values(description="Newer user edit") + ) + elif change == "issue_edit": + await editor.execute( + update(Issue).where(Issue.id == issue_id).values(title="Newer issue edit") + ) + elif change == "identity": + await editor.execute( + update(SeriesExternalIdentity).values( + revision=2, verification_state="stale" + ) + ) + elif change == "policy": + await editor.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values(revision=2) + ) + else: + await editor.execute(update(SeriesMetadataBaseline).values(revision=2)) + adapter.wait.set() + with pytest.raises(SeriesRefreshError, match="changed"): + await task + await session.commit() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + current = await session.get(Series, series_id) + assert current.description == ( + "Newer user edit" if change == "series_edit" else "Original provider description" + ) + assert await session.scalar(select(func.count()).select_from(Issue)) == 1 + + +@pytest.mark.parametrize( + "change", ["missing", "renumbered", "wrong_parent", "replaced_identity", "source_failure"] +) +async def test_catalog_disagreement_never_deletes_or_reassigns_existing_issue( + identity_probe_db, change +): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + if change == "missing": + data.series.issue_count = 0 + data = replace(data, issues=(), catalog_total=0) + elif change == "renumbered": + data.issues[0].issue_number_text = "2" + elif change == "wrong_parent": + data.issues[0].series_external_id = "99" + elif change == "replaced_identity": + data.issues[0].external_id = "999" + adapter = RefreshAdapter(data) + if change == "source_failure": + adapter.failure = SourceStatus.RATE_LIMITED + async with factory() as session: + with pytest.raises(SeriesRefreshError): + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(adapter) + ) + await session.commit() + async with factory() as session: + issue = await session.get(Issue, issue_id) + assert issue.issue_number_text == "1" and issue.status is IssueStatus.OWNED + assert (await session.scalar(select(IssueExternalIdentity))).external_id == "100" + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + + +async def test_refresh_does_not_invent_provenance_for_legacy_user_metadata(identity_probe_db): + from sqlalchemy import delete + + from pullbox.models.metadata_baseline import IssueMetadataBaseline + + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + async with factory.begin() as session: + await session.execute(delete(SeriesMetadataBaseline)) + await session.execute(delete(IssueMetadataBaseline)) + data.series.description = "Different provider text" + async with factory() as session: + result = await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + assert result is not None + assert result.description == "Original provider description" + await session.commit() + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.SERIES, series_id) + origin = next(item for item in saved.snapshot.origins if item.field == "description") + assert origin.source is None + + +async def test_existing_refresh_endpoint_uses_native_identity_and_preserves_response( + identity_probe_db, monkeypatch +): + from pullbox.api.v1 import series as routes + from pullbox.models.user import User + from pullbox.services import metadata_series_refresh as refresh + + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + data.series.description = "Endpoint refreshed" + adapter = RefreshAdapter(data) + instance = refresh_registry(adapter) + monkeypatch.setattr(refresh, "MetadataSourceRegistry", lambda *args, **kwargs: instance) + async with factory() as session: + response = await routes.refresh_series(series_id, User(username="tester"), session) + assert response.description == "Endpoint refreshed" + assert response.id == series_id + await session.commit() + assert adapter.calls == [("series", "42"), ("issues", "42", 1)] + + +async def test_hundred_issue_refresh_keeps_database_reads_bounded(identity_probe_db): + from sqlalchemy import event + + engine, factory, _ = identity_probe_db + series_id, _, data = await seed(factory, count=101) + reads = [] + + def record(_conn, _cursor, statement, *_rest): + if statement.lstrip().upper().startswith("SELECT"): + reads.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", record) + try: + async with factory() as session: + result = await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + assert result.issue_count == 101 + await session.commit() + finally: + event.remove(engine.sync_engine, "before_cursor_execute", record) + assert len(reads) < 60, f"Refresh issued {len(reads)} reads for 101 issues" + + +async def test_new_issue_crosswalk_cannot_duplicate_an_owned_issue(identity_probe_db): + from pullbox.core.metadata_identity import ExternalIdentityRef + + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + async with factory.begin() as session: + other = Series(title="Other", sort_title="Other") + session.add(other) + await session.flush() + session.add( + Issue(series_id=other.id, comicvine_id=900, issue_number=2, status=IssueStatus.OWNED) + ) + data = bundle(numbers=("1", "2")) + data.issues[1].cross_identities = [ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "900")] + async with factory() as session: + with pytest.raises(SeriesRefreshError): + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + await session.commit() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Issue)) == 2 + + +async def test_cancellation_drains_provider_and_leaves_catalog_and_baseline_unchanged( + identity_probe_db, +): + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + adapter = RefreshAdapter(data, wait=asyncio.Event()) + async with factory() as session: + task = asyncio.create_task( + refresh_series_from_sources(session, series_id, registry=refresh_registry(adapter)) + ) + await asyncio.wait_for(adapter.started.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not session.in_transaction() + assert adapter.closed == 1 + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + + +async def test_caller_rollback_undoes_catalog_addition_and_baselines(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + data = bundle(numbers=("1", "2")) + async with factory() as session: + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + await session.rollback() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Issue)) == 1 + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + + +async def test_dirty_session_is_not_rolled_back_by_refresh(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + adapter = RefreshAdapter(data) + async with factory() as session: + current = await session.get(Series, series_id) + current.title = "Pending user edit" + with pytest.raises(SeriesRefreshError, match="pending"): + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(adapter) + ) + assert current.title == "Pending user edit" + await session.commit() + assert adapter.calls == [] + + +async def test_background_fill_does_not_replace_managed_descriptions(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + data.series.description = "Replacement" + data.issues[0].title = "Replacement issue" + data.issues[0].description = "Previously missing" + async with factory() as session: + result = await refresh_series_from_sources( + session, + series_id, + registry=refresh_registry(RefreshAdapter(data)), + replace_managed=False, + ) + assert result.description == "Original provider description" + issue = await session.get(Issue, issue_id) + assert issue.title == "Original issue title" and issue.description == "Previously missing" + await session.commit() + + +async def test_failed_response_rolls_back_actual_refresh_command(identity_probe_db, monkeypatch): + from pullbox.api.v1 import series as routes + from pullbox.models.user import User + from pullbox.services import metadata_series_refresh as refresh + + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + data.series.description = "Must roll back" + instance = refresh_registry(RefreshAdapter(data)) + monkeypatch.setattr(refresh, "MetadataSourceRegistry", lambda *args, **kwargs: instance) + + async def fail(*_args): + raise RuntimeError("response failed") + + monkeypatch.setattr(routes, "_load_series_response", fail) + async with factory() as session: + with pytest.raises(RuntimeError, match="response failed"): + await routes.refresh_series(series_id, User(username="tester"), session) + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + assert (await session.get(Series, series_id)).description == "Original provider description" + + +@pytest.mark.parametrize("race", [False, True]) +async def test_cover_fetch_occurs_after_commit_and_rechecks_url( + identity_probe_db, tmp_path, monkeypatch, race +): + from pullbox.api.v1 import series as routes + from pullbox.models.user import User + from pullbox.services import metadata_series_refresh as refresh + + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + url = "https://static.metron.cloud/media/new.jpg" + data.series.image_url = url + data.series.description = "Committed before artwork" + instance = refresh_registry(RefreshAdapter(data)) + monkeypatch.setattr(refresh, "MetadataSourceRegistry", lambda *args, **kwargs: instance) + + async def covers(_session): + return tmp_path + + monkeypatch.setattr(refresh, "resolve_covers_dir", covers) + async with factory() as session: + + async def download(_client, actual_url, destination): + assert actual_url == url and not session.in_transaction() + async with factory.begin() as observer: + current = await observer.get(Series, series_id) + assert current.description == "Committed before artwork" + if race: + current.cover_url = "https://static.metron.cloud/media/newer.jpg" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"test artwork") + return True + + monkeypatch.setattr(refresh.ProviderArtworkClient, "download_cover", download) + await routes.refresh_series(series_id, User(username="tester"), session) + assert (tmp_path / str(series_id) / "series.jpg").exists() is (not race) + assert list(tmp_path.rglob(".*series.jpg")) == [] + + +async def test_core_and_catalog_use_independent_configured_priorities(identity_probe_db): + from pullbox.core.metadata_identity import ExternalIdentityRef + from pullbox.schemas.metadata_sources import MetadataDomain, SourceCapability + from pullbox.services.metadata_discovery import MetadataSourceRegistry + from pullbox.services.metadata_identity_attachment import attach_verified_identities + from pullbox.services.metadata_series_adoption import _request + from pullbox.services.metadata_sources import SourceRuntime, read_source_policies + from tests.unit.test_metadata_discovery import registration + + _, factory, _ = identity_probe_db + series_id, issue_id, metron_data = await seed(factory) + cv_data = bundle(numbers=("1", "2")) + cv_data.series.source = Source.COMICVINE_API + cv_data.series.identity_namespace = Namespace.COMICVINE + cv_data.series.external_id = "500" + cv_data.series.description = "Lower priority core text" + for index, issue in enumerate(cv_data.issues): + issue.source = Source.COMICVINE_API + issue.identity_namespace = Namespace.COMICVINE + issue.external_id = str(700 + index) + issue.series_external_id = "500" + async with factory.begin() as session: + await attach_verified_identities( + session, + [ + _request( + cv_data, + cv_data.series, + ExternalIdentityRef(Namespace.COMICVINE, Kind.SERIES, "500"), + series_id, + ), + _request( + cv_data, + cv_data.issues[0], + ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "700"), + issue_id, + ), + ], + ) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values(domain_priorities={"core": 0, "issues": 100}) + ) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.COMICVINE_API.value) + .values(domain_priorities={"core": 100, "issues": 0}) + ) + policies = await read_source_policies(session) + metron_data.series.description = "Preferred core text" + adapters = { + Source.METRON_API: RefreshAdapter(metron_data), + Source.COMICVINE_API: RefreshAdapter(cv_data), + } + adapters[Source.COMICVINE_API].source = Source.COMICVINE_API + instance = MetadataSourceRegistry( + [SourceRuntime(policy) for policy in policies if policy.source in adapters], + factories={ + source: registration(adapter, capabilities=list(SourceCapability)) + for source, adapter in adapters.items() + }, + ) + async with factory() as session: + result = await refresh_series_from_sources(session, series_id, registry=instance) + assert result.description == "Preferred core text" + assert result.issue_count == 2 + saved = await load_metadata_baseline(session, Kind.SERIES, series_id) + description = next(item for item in saved.snapshot.origins if item.field == "description") + assert description.source is Source.METRON_API and description.domain is MetadataDomain.CORE + await session.commit() + assert adapters[Source.METRON_API].calls == [("series", "42")] + assert adapters[Source.COMICVINE_API].calls == [("series", "500"), ("issues", "500", 1)] + + +async def test_unknown_issue_crosswalk_remains_observation_not_ownership(identity_probe_db): + from pullbox.core.metadata_identity import ExternalIdentityRef + from pullbox.models.metadata_identity import IssueIdentityEvent + + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + data = bundle(numbers=("1", "2")) + data.issues[1].cross_identities = [ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "999")] + async with factory() as session: + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(RefreshAdapter(data)) + ) + await session.commit() + async with factory() as session: + issue = await session.scalar(select(Issue).where(Issue.issue_number_text == "2")) + assert issue.comicvine_id is None + event = await session.scalar( + select(IssueIdentityEvent).where( + IssueIdentityEvent.identity_namespace == Namespace.COMICVINE + ) + ) + assert event.verification_state.value == "observed" + + +@pytest.mark.parametrize( + "failure", [SourceStatus.RATE_LIMITED, SourceStatus.NOT_FOUND, SourceStatus.TIMEOUT] +) +async def test_failed_profile_is_not_retried_again_for_catalog_in_same_refresh( + identity_probe_db, failure +): + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + adapter = RefreshAdapter(data) + adapter.profile_failure = failure + async with factory() as session: + with pytest.raises(SeriesRefreshError): + await refresh_series_from_sources( + session, series_id, registry=refresh_registry(adapter) + ) + assert adapter.calls == [("series", "42")] diff --git a/tests/unit/test_series_api_branches.py b/tests/unit/test_series_api_branches.py index 13304261..766fc43f 100644 --- a/tests/unit/test_series_api_branches.py +++ b/tests/unit/test_series_api_branches.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace from typing import TYPE_CHECKING @@ -267,13 +268,18 @@ async def test_add_update_refresh_and_folder_routes_delegate( rename_all_series_folders=AsyncMock(return_value={"renamed": 2, "skipped": 1}), rename_series_folder=AsyncMock(return_value="/comics/Absolute Superman (2025)"), ) - metadata_service = SimpleNamespace(refresh_series=AsyncMock()) + refresh_command = AsyncMock() + + @asynccontextmanager + async def refresh_transaction(session, series_id): + await refresh_command(session, series_id) + yield + load_response = AsyncMock(side_effect=lambda _session, series_id: _series_response(series_id)) monkeypatch.setattr(series_api, "_build_series_service", AsyncMock(return_value=service)) monkeypatch.setattr( - series_api, - "_build_metadata_service", - AsyncMock(return_value=metadata_service), + "pullbox.services.metadata_series_refresh.source_series_refresh_transaction", + refresh_transaction, ) monkeypatch.setattr(series_api, "_load_series_response", load_response) monkeypatch.setattr(series_api, "load_search_on_add_default", AsyncMock(return_value=True)) @@ -339,10 +345,9 @@ async def test_add_update_refresh_and_folder_routes_delegate( refresh_target = await _seed_series(db_session, title="Refresh Target") refreshed = await series_api.refresh_series(refresh_target.id, _user(), db_session) assert refreshed.id == refresh_target.id - metadata_service.refresh_series.assert_awaited_once_with( + refresh_command.assert_awaited_once_with( db_session, refresh_target.id, - force=True, ) @@ -354,11 +359,10 @@ async def test_refresh_series_rejects_duplicate_initial_catalog_sync( series = await _seed_series(db_session, title="Hydrating Refresh Target") series.issue_catalog_state = IssueCatalogState.HYDRATING await db_session.commit() - metadata_service = SimpleNamespace(refresh_series=AsyncMock()) + refresh_command = AsyncMock() monkeypatch.setattr( - series_api, - "_build_metadata_service", - AsyncMock(return_value=metadata_service), + "pullbox.services.metadata_series_refresh.source_series_refresh_transaction", + refresh_command, ) with pytest.raises(HTTPException) as exc_info: @@ -366,7 +370,7 @@ async def test_refresh_series_rejects_duplicate_initial_catalog_sync( assert exc_info.value.status_code == 409 assert exc_info.value.detail == "Initial metadata sync is already in progress." - metadata_service.refresh_series.assert_not_awaited() + refresh_command.assert_not_called() @pytest.mark.asyncio From 5233f82713c75ac2cfe78c749f87f2f1e490924f Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 13:57:10 -0700 Subject: [PATCH 45/97] fix(metadata): complete manual refresh failure handling Show actionable refresh conflicts without reloading, and keep committed metadata successful when optional cover caching fails. Cover native-source refresh, provider-profile reuse, and isolated Chromium/Firefox behavior. --- .../services/metadata_series_refresh.py | 12 +++- src/pullbox/ui/static/js/pullbox.js | 13 +++- tests/e2e/test_series_detail_page.py | 46 ++++++++++++++ tests/e2e/test_source_series_add.py | 32 ++++++++-- .../metadata_identity/test_series_refresh.py | 60 +++++++++++++++++++ .../test_metadata_series_adoption_fetch.py | 26 ++++++++ 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index e34dd18f..92ee125e 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -8,7 +8,7 @@ import structlog from sqlalchemy import select -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from pullbox.config import get_settings @@ -78,7 +78,15 @@ async def source_series_refresh_transaction( await session.rollback() raise if cover_url: - await _refresh_artwork(session, series_id, cover_url, covers) + try: + await _refresh_artwork(session, series_id, cover_url, covers) + except (OSError, SQLAlchemyError) as exc: + # Metadata is already committed; an optional cache write is retryable. + logger.warning( + "metadata_series_artwork_refresh_failed", + series_id=series_id, + error_type=type(exc).__name__, + ) async def _refresh_artwork(session: AsyncSession, series_id: int, url: str, covers: Path) -> None: diff --git a/src/pullbox/ui/static/js/pullbox.js b/src/pullbox/ui/static/js/pullbox.js index a48c7453..dab9a363 100644 --- a/src/pullbox/ui/static/js/pullbox.js +++ b/src/pullbox/ui/static/js/pullbox.js @@ -19959,19 +19959,26 @@ function seriesDetailPage(config) { var self = this; if (self.refreshing) return; self.refreshing = true; + var failureMessage = "Failed to refresh metadata"; fetch(cfg.refreshUrl, { method: "POST", headers: { "X-CSRF-Token": self.csrfToken() }, }) - .then(function (response) { - if (!response.ok) throw new Error("Failed to refresh metadata"); + .then(async function (response) { + if (!response.ok) { + var data = await _readJsonBody(response); + if (response.status === 409) { + failureMessage = _extractApiErrorMessage(response, data, failureMessage); + } + throw new Error(failureMessage); + } self.dispatchToast("Metadata refreshed", "success"); setTimeout(function () { window.location.assign(self.currentDetailUrl()); }, 500); }) .catch(function () { - self.dispatchToast("Failed to refresh metadata", "error"); + self.dispatchToast(failureMessage, "error"); self.refreshing = false; }); }, diff --git a/tests/e2e/test_series_detail_page.py b/tests/e2e/test_series_detail_page.py index 3fa74071..91367b2a 100644 --- a/tests/e2e/test_series_detail_page.py +++ b/tests/e2e/test_series_detail_page.py @@ -16,6 +16,52 @@ class TestSeriesDetailPage: """Behavior-first E2E coverage for /series/{id}.""" + @pytest.mark.parametrize("theme", ["light", "dark"]) + def test_metadata_refresh_conflict_is_actionable_without_reloading( + self, authed_page, seeded_server, theme + ): + page = authed_page + SeriesDetailPage(page, seeded_server).goto(1) + page.evaluate("theme => applyTheme(theme)", theme) + page.evaluate("window.refreshDocumentMarker = 'unchanged'") + detail = ( + "A provider issue was renumbered. Review the issue match; existing files were kept." + ) + page.route( + "**/api/v1/series/1/refresh", + lambda route: route.fulfill(status=409, json={"detail": detail}), + ) + button = page.get_by_test_id("series-action-refresh") + button.click() + expect(page.get_by_text(detail, exact=True)).to_be_visible() + expect(page.locator("#toast-container > div").filter(has_text=detail)).to_be_in_viewport( + ratio=1 + ) + expect(button).to_be_enabled() + assert page.evaluate("window.refreshDocumentMarker") == "unchanged" + + @pytest.mark.parametrize("failure", ["html", "malformed_detail", "network"]) + def test_metadata_refresh_unexpected_failure_is_retryable( + self, authed_page, seeded_server, failure + ): + page = authed_page + SeriesDetailPage(page, seeded_server).goto(1) + + def fail(route): + if failure == "network": + route.abort() + elif failure == "html": + route.fulfill(status=502, content_type="text/html", body="

Gateway error

") + else: + route.fulfill(status=500, json={"detail": {"internal": "not for display"}}) + + page.route("**/api/v1/series/1/refresh", fail) + button = page.get_by_test_id("series-action-refresh") + button.click() + expect(page.get_by_text("Failed to refresh metadata", exact=True)).to_be_visible() + expect(button).to_be_enabled() + expect(page.get_by_text("not for display", exact=True)).not_to_be_visible() + def test_automatic_search_reports_queue_failure_instead_of_no_results( self, authed_page, diff --git a/tests/e2e/test_source_series_add.py b/tests/e2e/test_source_series_add.py index 7a844e57..c8e63a45 100644 --- a/tests/e2e/test_source_series_add.py +++ b/tests/e2e/test_source_series_add.py @@ -200,7 +200,7 @@ def respond(route): def test_real_local_search_preview_add_refresh_and_existing_owner( - authed_page, seeded_server, monkeypatch, tmp_path + authed_page, seeded_server, monkeypatch, tmp_path, request ): from pullbox.api.v1 import series as series_api from pullbox.providers.metadata import sources @@ -233,6 +233,17 @@ def test_real_local_search_preview_add_refresh_and_existing_owner( response = added.value assert response.status == 201, response.text() record = response.json() + + def remove_test_series(): + result = page.request.delete( + f"{seeded_server}/api/v1/series/{record['id']}", + headers={"X-CSRF-Token": page.evaluate("readCsrfTokenFromBody()")}, + ) + assert result.status == 204, result.text() + # Delete only the empty folder this test's Add created, not library data. + Path(record["path"]).rmdir() + + request.addfinalizer(remove_test_series) assert record["comicvine_id"] == 10 and record["title"] == "Batman" assert Path(record["path"]).is_dir() assert Path(record["path"]).name == folder @@ -242,12 +253,21 @@ def test_real_local_search_preview_add_refresh_and_existing_owner( ) assert events.emit.await_count == 1 page.goto(f"{seeded_server}/series/{record['id']}") - with page.expect_response( - lambda response: response.url.endswith(f"/series/{record['id']}/refresh") - ) as refreshed: + with ( + page.expect_navigation( + url=f"{seeded_server}/series/{record['id']}", wait_until="domcontentloaded" + ), + page.expect_response( + lambda response: response.url.endswith(f"/series/{record['id']}/refresh") + ) as refreshed, + ): page.get_by_test_id("series-action-refresh").click() - assert refreshed.value.status == 200, refreshed.value.text() - assert refreshed.value.json()["issue_catalog_state"] == "complete" + assert refreshed.value.status == 200 + # The success action navigates away. Verify persisted state without waiting + # for a response body attached to the previous document's fetch lifecycle. + updated = page.request.get(f"{seeded_server}/api/v1/series/{record['id']}") + assert updated.ok + assert updated.json()["issue_catalog_state"] == "complete" expect(page.get_by_test_id("series-action-refresh")).to_be_enabled() detail = "A provider issue was renumbered. Review the issue match; existing files were kept." page.route( diff --git a/tests/integration/metadata_identity/test_series_refresh.py b/tests/integration/metadata_identity/test_series_refresh.py index 3e2a8b69..b2e510dc 100644 --- a/tests/integration/metadata_identity/test_series_refresh.py +++ b/tests/integration/metadata_identity/test_series_refresh.py @@ -2,9 +2,12 @@ import asyncio from dataclasses import replace +from pathlib import Path import pytest from sqlalchemy import func, select, update +from sqlalchemy.exc import OperationalError +from structlog.testing import capture_logs from pullbox.core.metadata_identity import IdentityNamespace as Namespace from pullbox.core.metadata_identity import MetadataEntityKind as Kind @@ -459,6 +462,63 @@ async def download(_client, actual_url, destination): assert list(tmp_path.rglob(".*series.jpg")) == [] +@pytest.mark.parametrize("failure", ["download", "publish", "database"]) +async def test_artwork_failure_does_not_report_committed_metadata_as_failed( + identity_probe_db, tmp_path, monkeypatch, failure +): + from pullbox.api.v1 import series as routes + from pullbox.models.user import User + from pullbox.services import metadata_series_refresh as refresh + + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + data.series.image_url = "https://static.metron.cloud/media/new.jpg" + data.series.description = "Committed metadata" + instance = refresh_registry(RefreshAdapter(data)) + monkeypatch.setattr(refresh, "MetadataSourceRegistry", lambda *args, **kwargs: instance) + + async def covers(_session): + return tmp_path + + destination = tmp_path / str(series_id) / "series.jpg" + destination.parent.mkdir() + destination.write_bytes(b"existing cover") + + async def download(_client, _url, pending): + if failure == "download": + raise PermissionError("private storage diagnostic") + pending.write_bytes(b"replacement cover") + if failure == "database": + + async def fail_cache_read(*args, **kwargs): + raise OperationalError("cover lookup", {}, Exception("private storage diagnostic")) + + monkeypatch.setattr(session, "scalar", fail_cache_read) + return True + + original_replace = Path.replace + + def reject_publish(path, target): + if target == destination: + raise PermissionError("private storage diagnostic") + return original_replace(path, target) + + monkeypatch.setattr(refresh, "resolve_covers_dir", covers) + monkeypatch.setattr(refresh.ProviderArtworkClient, "download_cover", download) + monkeypatch.setattr(Path, "replace", reject_publish) + with capture_logs() as logs: + async with factory() as session: + response = await routes.refresh_series(series_id, User(username="tester"), session) + assert response.description == "Committed metadata" + assert not session.in_transaction() + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 2 + assert destination.read_bytes() == b"existing cover" + assert list(tmp_path.rglob(".*series.jpg")) == [] + assert any(item["event"] == "metadata_series_artwork_refresh_failed" for item in logs) + assert "private storage diagnostic" not in str(logs) + + async def test_core_and_catalog_use_independent_configured_priorities(identity_probe_db): from pullbox.core.metadata_identity import ExternalIdentityRef from pullbox.schemas.metadata_sources import MetadataDomain, SourceCapability diff --git a/tests/unit/test_metadata_series_adoption_fetch.py b/tests/unit/test_metadata_series_adoption_fetch.py index 256be91a..973922ef 100644 --- a/tests/unit/test_metadata_series_adoption_fetch.py +++ b/tests/unit/test_metadata_series_adoption_fetch.py @@ -84,6 +84,32 @@ async def test_source_revision_changed_before_fetch_makes_no_requests(): assert adapter.calls == [] +async def test_prefetched_series_profile_is_reused_for_complete_catalog(): + adapter = CatalogAdapter(101) + profile = row(adapter.source).model_copy(update={"issue_count": 101}) + result = await fetch(adapter, profile=profile) + assert result.series == profile and len(result.issues) == 101 + assert adapter.calls == [("issues", "42", 1), ("issues", "42", 2)] + + +@pytest.mark.parametrize("change", ["source", "identity_namespace", "external_id"]) +async def test_prefetched_series_profile_cannot_cross_source_identity(change): + adapter = CatalogAdapter(1) + profile = row(adapter.source) + profile = profile.model_copy( + update={ + change: { + "source": Source.COMICVINE_API, + "identity_namespace": Source.COMICVINE_API.identity_namespace, + "external_id": "999", + }[change] + } + ) + with pytest.raises(SeriesAdoptionError, match="different source identity"): + await fetch(adapter, profile=profile) + assert adapter.calls == [] + + @pytest.mark.parametrize( "status", [SourceStatus.RATE_LIMITED, SourceStatus.TIMEOUT, SourceStatus.NOT_FOUND] ) From d06e512db205fdb60513c8016d2edc700012291c Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 14:22:03 -0700 Subject: [PATCH 46/97] feat(metadata): refresh scheduled series through configured sources --- docs/development/ARCHITECTURE_OVERVIEW.md | 11 +- .../services/metadata_scheduled_refresh.py | 70 ++++ .../services/metadata_series_refresh.py | 50 ++- src/pullbox/tasks/metadata_sweep_state.py | 10 +- src/pullbox/tasks/metadata_task.py | 118 ++++-- .../test_scheduled_series_refresh.py | 386 ++++++++++++++++++ tests/tasks/test_metadata_search_trigger.py | 12 + tests/tasks/test_scheduler_wrappers.py | 16 +- 8 files changed, 623 insertions(+), 50 deletions(-) create mode 100644 src/pullbox/services/metadata_scheduled_refresh.py create mode 100644 tests/integration/metadata_identity/test_scheduled_series_refresh.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index ce220adb..799c6351 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -650,7 +650,16 @@ checks a two-minute budget between series; an individual series has a separate after restart. Provider throttling pauses the sweep at its saved position instead of repeatedly failing every remaining series. Series metadata writes are committed before subsequent cover or issue-provider waits. -Removing the ComicVine key stops the active sweep and clears its continuation. +The scheduled metadata refresh selects verified identities backed by executable, +configured sources, including native Metron and local ComicVine without an API +key. It uses the same revision-checked writer as manual refresh but fills gaps +only, preserving existing descriptive values and user overrides. Its metadata +write and sweep checkpoint commit together before search scheduling or optional +cover-cache work. Structured source failures preserve durable retry deadlines; +disabling the last eligible source clears the continuation. +The separate daily issue sweep still uses the legacy ComicVine recent/full +catalog path; removing its ComicVine key stops that sweep and clears its +continuation. Source-aware recent-catalog integration remains required. Post-restore aftercare keeps its recovery marker while continuation batches remain active; it observes completion without retaining a database transaction between checks. Cancellation leaves the marker and sweep checkpoint available for the next startup. diff --git a/src/pullbox/services/metadata_scheduled_refresh.py b/src/pullbox/services/metadata_scheduled_refresh.py new file mode 100644 index 00000000..8d99a64c --- /dev/null +++ b/src/pullbox/services/metadata_scheduled_refresh.py @@ -0,0 +1,70 @@ +"""Source eligibility and caller-owned background refresh of existing series.""" + +from dataclasses import dataclass +from pathlib import Path + +from sqlalchemy import exists, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_identity import SeriesExternalIdentity +from pullbox.schemas.metadata_sources import SourceCapability +from pullbox.services.cover_resolver import resolve_covers_dir +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_series_refresh import refresh_series_from_sources +from pullbox.services.metadata_sources import load_source_runtime + + +async def scheduled_series_eligibility( + session: AsyncSession, *, gcd_api_enabled: bool +) -> ColumnElement[bool]: + """Use executable configured sources, not cached health or compatibility IDs.""" + registry = MetadataSourceRegistry( + await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled), + gcd_api_enabled=gcd_api_enabled, + ) + namespaces = { + source.identity_namespace + for source, runtime in registry.runtime.items() + if registry.source_availability(source, SourceCapability.SERIES_DETAILS) is None + and registry.source_availability(source, SourceCapability.ISSUE_LIST) is None + and ( + source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL} + or runtime.credential is not None + ) + } + return exists().where( + SeriesExternalIdentity.series_id == Series.id, + SeriesExternalIdentity.identity_namespace.in_(namespaces), + SeriesExternalIdentity.verification_state == IdentityVerificationState.VERIFIED, + ) + + +@dataclass(frozen=True) +class ScheduledSeriesRefresh: + added: int + search_wanted: bool + cover_url: str | None + covers: Path + + +async def refresh_scheduled_series(session: AsyncSession, series_id: int) -> ScheduledSeriesRefresh: + """Fill gaps and synchronize a complete catalog; the task commits progress too.""" + covers = await resolve_covers_dir(session) + before_id = await session.scalar(select(func.max(Issue.id)).where(Issue.series_id == series_id)) + series = await refresh_series_from_sources(session, series_id, replace_managed=False) + new_issues = list( + await session.scalars( + select(Issue).where(Issue.series_id == series_id, Issue.id > (before_id or 0)) + ) + ) + return ScheduledSeriesRefresh( + len(new_issues), + series.monitored and any(issue.status is IssueStatus.WANTED for issue in new_issues), + series.cover_url, + covers, + ) diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index 92ee125e..8f45e591 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -23,6 +23,7 @@ MetadataDomain, ProviderSeriesRead, SourceCapability, + SourceOutcome, SourceStatus, ) from pullbox.services.cover_resolver import resolve_covers_dir @@ -60,6 +61,17 @@ class SeriesRefreshError(ValueError): """The refresh requires a new read or an explicit identity decision.""" + def __init__( + self, + message: str, + *, + outcomes: tuple[SourceOutcome, ...] = (), + retry_after_seconds: int | None = None, + ) -> None: + super().__init__(message) + self.outcomes = outcomes + self.retry_after_seconds = retry_after_seconds + @asynccontextmanager async def source_series_refresh_transaction( @@ -78,15 +90,21 @@ async def source_series_refresh_transaction( await session.rollback() raise if cover_url: - try: - await _refresh_artwork(session, series_id, cover_url, covers) - except (OSError, SQLAlchemyError) as exc: - # Metadata is already committed; an optional cache write is retryable. - logger.warning( - "metadata_series_artwork_refresh_failed", - series_id=series_id, - error_type=type(exc).__name__, - ) + await refresh_series_artwork(session, series_id, cover_url, covers) + + +async def refresh_series_artwork( + session: AsyncSession, series_id: int, url: str, covers: Path +) -> None: + """Best-effort optional cache work after the caller commits metadata.""" + try: + await _refresh_artwork(session, series_id, url, covers) + except (OSError, SQLAlchemyError) as exc: + logger.warning( + "metadata_series_artwork_refresh_failed", + series_id=series_id, + error_type=type(exc).__name__, + ) async def _refresh_artwork(session: AsyncSession, series_id: int, url: str, covers: Path) -> None: @@ -171,7 +189,7 @@ async def refresh_series_from_sources( if isinstance(item, ProviderSeriesRead) } failed = { - item.source + item.source: item for item in fetched.outcomes if item.status not in {SourceStatus.OK, SourceStatus.NOT_QUERIED} } @@ -229,7 +247,8 @@ async def refresh_series_from_sources( ) from exc except TimeoutError as exc: raise SeriesRefreshError( - "Metadata refresh timed out. No library metadata was changed; retry later." + "Metadata refresh timed out. No library metadata was changed; retry later.", + retry_after_seconds=300, ) from exc @@ -237,7 +256,7 @@ async def _catalog( registry: MetadataSourceRegistry, state: SeriesRefreshState, profiles: dict[MetadataSource, ProviderSeriesRead], - failed: set[MetadataSource], + failed: dict[MetadataSource, SourceOutcome], ) -> SourceSeriesBundle: known = {item.namespace: item.external_id for item in state.series.identities} sources = sorted( @@ -275,8 +294,13 @@ async def _catalog( logger.info( "metadata_series_catalog_unavailable", source=source.value, status=exc.status.value ) + failed[source] = SourceOutcome( + source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds + ) raise SeriesRefreshError( - "No configured source could supply a complete issue catalog. Check source status and retry." + "No configured source could supply a complete issue catalog. " + "Check source status and retry.", + outcomes=tuple(failed.values()), ) diff --git a/src/pullbox/tasks/metadata_sweep_state.py b/src/pullbox/tasks/metadata_sweep_state.py index 1398f9fb..7f45e441 100644 --- a/src/pullbox/tasks/metadata_sweep_state.py +++ b/src/pullbox/tasks/metadata_sweep_state.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql.elements import ColumnElement TASK_IDS = ("sync_new_issues", "refresh_metadata") @@ -48,12 +49,13 @@ async def load_sweep(session: AsyncSession, task_id: str) -> MetadataSweep: return MetadataSweep() -async def start_sweep(session: AsyncSession, task_id: str) -> MetadataSweep: +async def start_sweep( + session: AsyncSession, task_id: str, *, eligible: ColumnElement[bool] | None = None +) -> MetadataSweep: state = await load_sweep(session, task_id) if not state.active: - upper = await session.scalar( - select(func.max(Series.id)).where(Series.comicvine_id.isnot(None)) - ) + predicate = eligible if eligible is not None else Series.comicvine_id.isnot(None) + upper = await session.scalar(select(func.max(Series.id)).where(predicate)) state = MetadataSweep(upper_bound=upper or 0, active=True) await save_sweep(session, task_id, state) await session.commit() diff --git a/src/pullbox/tasks/metadata_task.py b/src/pullbox/tasks/metadata_task.py index bc47378b..ccd66643 100644 --- a/src/pullbox/tasks/metadata_task.py +++ b/src/pullbox/tasks/metadata_task.py @@ -34,6 +34,13 @@ from pullbox.models.issue import Issue, IssueStatus from pullbox.models.series import IssueCatalogState, Series, SeriesStatus from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider +from pullbox.schemas.metadata_sources import SourceStatus +from pullbox.services.metadata_scheduled_refresh import ( + ScheduledSeriesRefresh, + refresh_scheduled_series, + scheduled_series_eligibility, +) +from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_artwork from pullbox.services.metadata_service import MetadataService from pullbox.tasks.metadata_sweep_state import load_sweep, save_sweep, schedule_sweep, start_sweep @@ -262,6 +269,30 @@ def _provider_pause_seconds(exc: Exception) -> float | None: return 60 if isinstance(exc, TimeoutError): return 300 + if isinstance(exc, SeriesRefreshError): + if exc.retry_after_seconds is not None: + return float(max(60, exc.retry_after_seconds)) + delays = [ + max( + 60, + outcome.retry_after_seconds + or ( + 3600 + if outcome.status + in {SourceStatus.RATE_LIMITED, SourceStatus.AUTHENTICATION_FAILED} + else 300 + ), + ) + for outcome in exc.outcomes + if outcome.status + in { + SourceStatus.RATE_LIMITED, + SourceStatus.AUTHENTICATION_FAILED, + SourceStatus.TIMEOUT, + SourceStatus.UNAVAILABLE, + } + ] + return float(min(delays)) if delays else None details = exc.details or {} if isinstance(exc, ProviderError) else {} status = exc.status_code if isinstance(exc, ComicVineError) else details.get("status_code") retryable = exc.retryable if isinstance(exc, ComicVineError) else details.get("retryable") @@ -281,8 +312,9 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: series_to_search: list[int] = [] started = time.monotonic() async with factory() as session: - api_key = await get_comicvine_api_key(session) - if not api_key: + source_refresh = task_id == "refresh_metadata" + api_key = None if source_refresh else await get_comicvine_api_key(session) + if not source_refresh and not api_key: state = await load_sweep(session, task_id) if state.active: state.active = False @@ -298,15 +330,17 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: ) return TaskExecutionResult(status="completed") - state = await start_sweep(session, task_id) - schedule_sweep(task_id, state) - if state.retry_at > datetime.now(UTC).timestamp(): - schedule_sweep(task_id, state) - return TaskExecutionResult(status="waiting") - + eligible = ( + await scheduled_series_eligibility( + session, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled + ) + if source_refresh + else Series.comicvine_id.isnot(None) + ) + state = await start_sweep(session, task_id, eligible=eligible) refresh_days = _metadata_refresh_days(settings) predicates = [ - Series.comicvine_id.isnot(None), + eligible, Series.id > state.cursor, Series.id <= state.upper_bound, ] @@ -314,6 +348,7 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: predicates.extend( [ Series.monitored.is_(True), + Series.issue_catalog_state != IssueCatalogState.HYDRATING, or_( Series.metadata_last_refreshed.is_(None), Series.metadata_last_refreshed @@ -339,6 +374,10 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: schedule_sweep(task_id, state) return TaskExecutionResult(status="completed") + schedule_sweep(task_id, state) + if state.retry_at > datetime.now(UTC).timestamp(): + return TaskExecutionResult(status="waiting") + counts = { int(series_id): int(count) for series_id, count in ( @@ -349,7 +388,9 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: ) ).all() } - metadata_svc = await _create_metadata_service(api_key, settings, session) + metadata_svc = ( + await _create_metadata_service(api_key, settings, session) if api_key else None + ) await session.commit() processed = failed = new_issues = 0 paused = False @@ -367,14 +408,13 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: try: previous_cursor = state.cursor search_after_commit = False + refreshed: ScheduledSeriesRefresh | None = None async with asyncio.timeout(_METADATA_SERIES_SECONDS): if task_id == "refresh_metadata": - await metadata_svc.refresh_series( - session, - series_id, - commit_before_provider_wait=True, - ) + refreshed = await refresh_scheduled_series(session, series_id) + search_after_commit = refreshed.search_wanted else: + assert metadata_svc is not None created, _mode, _sc, _mc = await _sync_one_series( metadata_svc, session, @@ -395,10 +435,11 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: state.retry_at = 0 await save_sweep(session, task_id, state) await session.commit() - if search_after_commit: + if search_after_commit and not source_refresh: series_to_search.append(series_id) processed += 1 except Exception as exc: + refreshed = None await session.rollback() state.cursor = previous_cursor # Rollback expires ORM objects; use the stable ID, not their fields. @@ -423,7 +464,28 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: state.cursor = series_id await save_sweep(session, task_id, state) await session.commit() - logger.exception(f"{task_id}_series_failed", series_id=series_id) + if isinstance(exc, SeriesRefreshError): + logger.warning( + f"{task_id}_series_failed", + series_id=series_id, + reason=str(exc), + source_statuses={ + item.source.value: item.status.value for item in exc.outcomes + }, + ) + else: + logger.exception(f"{task_id}_series_failed", series_id=series_id) + + # Artwork is optional and cannot roll back the committed cursor + # or postpone searches for newly committed wanted issues. + if refreshed is not None: + new_issues += refreshed.added + if refreshed.search_wanted: + _schedule_new_issue_search(series_id) + if refreshed.cover_url: + await refresh_series_artwork( + session, series_id, refreshed.cover_url, refreshed.covers + ) state.active = paused or processed < len(ids) await save_sweep(session, task_id, state) @@ -434,16 +496,8 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: await close() if series_to_search: - from pullbox.tasks.search_task import search_series_issues - for sid in series_to_search: - get_scheduler()._scheduler.add_job( - search_series_issues, - trigger="date", - args=[sid], - id=f"search_new_{sid}_{int(time.time())}", - misfire_grace_time=300, - ) + _schedule_new_issue_search(sid) schedule_sweep(task_id, state) logger.info( f"{task_id}_batch_complete", @@ -457,6 +511,18 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: return TaskExecutionResult(status="waiting" if state.active else "completed") +def _schedule_new_issue_search(series_id: int) -> None: + from pullbox.tasks.search_task import search_series_issues + + get_scheduler()._scheduler.add_job( + search_series_issues, + trigger="date", + args=[series_id], + id=f"search_new_{series_id}_{int(time.time())}", + misfire_grace_time=300, + ) + + async def sync_new_issues() -> TaskExecutionResult: """Resume a bounded all-series issue sweep without monopolizing the scheduler.""" return await _run_metadata_sweep("sync_new_issues") diff --git a/tests/integration/metadata_identity/test_scheduled_series_refresh.py b/tests/integration/metadata_identity/test_scheduled_series_refresh.py new file mode 100644 index 00000000..8ef7daf0 --- /dev/null +++ b/tests/integration/metadata_identity/test_scheduled_series_refresh.py @@ -0,0 +1,386 @@ +"""Scheduled native-source refresh keeps durable progress and library ownership.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import select, update + +from pullbox.core.comicvine_key import save_comicvine_api_key +from pullbox.core.encryption import encrypt_secret +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_identity import SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState, SeriesStatusOverride +from pullbox.schemas.metadata_sources import SourceStatus +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.services.metadata_series_adoption import adopt_source_series_bundle +from pullbox.tasks import metadata_task +from pullbox.tasks.metadata_sweep_state import MetadataSweep, load_sweep, save_sweep +from tests.integration.metadata_identity.test_series_adoption import ( # noqa: F401 + bundle, + configured_sources, +) +from tests.integration.metadata_identity.test_series_refresh import RefreshAdapter +from tests.unit.test_metadata_source_reads import registry + + +@pytest.fixture +async def scheduled(identity_probe_db, monkeypatch): + from pullbox.providers.metadata import sources + + _, factory, _ = identity_probe_db + scheduler = MagicMock() + monkeypatch.setattr(metadata_task, "get_session_factory", lambda: factory) + monkeypatch.setattr(metadata_task, "get_scheduler", lambda: scheduler) + monkeypatch.setattr("pullbox.tasks.metadata_sweep_state.get_scheduler", lambda: scheduler) + async with factory.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values(enabled=True, credential_secret=encrypt_secret("scheduled-test-token")) + ) + data = bundle(numbers=("1", "50-x")) + adapter = RefreshAdapter(data) + monkeypatch.setattr(sources, "metadata_sources", lambda: registry(adapter).factories) + return factory, adapter, scheduler + + +async def add_series( + factory, *, identifier=42, monitored=True, fresh=False, source=Source.METRON_API +): + data = bundle(numbers=("1",)) + data.series.external_id = str(identifier) + data.series.description = "Existing description" + data.series.image_url = None + data.issues[0].series_external_id = str(identifier) + data.issues[0].external_id = str(identifier * 100) + generation = datetime.now(UTC) + for row in (data.series, *data.issues): + row.source = source + row.identity_namespace = source.identity_namespace + if source is Source.COMICVINE_LOCAL: + row.source_updated_at = generation + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, data, monitored=monitored) + result.series.metadata_last_refreshed = datetime.now(UTC) - timedelta( + days=0 if fresh else 60 + ) + result.series.path = "/read-only/original" + series_id = result.series.id + issue = await session.scalar(select(Issue).where(Issue.series_id == series_id)) + issue.status = IssueStatus.OWNED + issue.manual_skip = True + return series_id, data + + +def offer(adapter, data, *, count=2): + rows = tuple( + data.issues[0].model_copy( + update={ + "external_id": str(int(data.issues[0].external_id) + i), + "issue_number_text": "1" if i == 0 else "50-x", + "issue_number_key": "1" if i == 0 else "50-X", + } + ) + for i in range(count) + ) + adapter.data = replace( + data, + series=data.series.model_copy(update={"issue_count": count}), + issues=rows, + catalog_total=count, + ) + + +async def test_scheduled_native_refresh_fills_gaps_without_cv_key_and_searches_after_commit( + scheduled, +): + factory, adapter, scheduler = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + adapter.data.series.description = "Do not overwrite" + adapter.data.issues[0].description = "Fill this missing value" + async with factory.begin() as session: + series = await session.get(Series, series_id) + series.status_override = SeriesStatusOverride.ENDED + series.status = "ended" + result = await metadata_task.refresh_metadata() + assert adapter.calls, "Native-only series must not require a ComicVine API key" + assert result.status == "completed" + async with factory() as session: + series = await session.get(Series, series_id) + assert series.description == "Existing description" + assert series.status.value == "ended" and series.path == "/read-only/original" + assert series.issue_count == 2 and series.issue_catalog_state is IssueCatalogState.COMPLETE + issues = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert issues[0].status is IssueStatus.OWNED and issues[0].manual_skip + assert issues[0].description == "Fill this missing value" + assert issues[1].status is IssueStatus.WANTED and issues[1].issue_number_text == "50-X" + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 2 + state = await load_sweep(session, "refresh_metadata") + assert not state.active and state.cursor == state.upper_bound == series_id + scheduler._scheduler.add_job.assert_called_once() + assert scheduler._scheduler.add_job.call_args.kwargs["args"] == [series_id] + + +async def test_total_deadline_is_retryable_without_fabricating_source_outcomes( + scheduled, monkeypatch +): + from pullbox.services import metadata_series_refresh as refresh + + factory, adapter, _ = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + monkeypatch.setattr(refresh, "fetch_metadata_snapshot", AsyncMock(side_effect=TimeoutError)) + async with factory() as session: + with pytest.raises(refresh.SeriesRefreshError) as failure: + await refresh.refresh_series_from_sources(session, series_id) + assert failure.value.outcomes == (), ( + "An operation deadline cannot assign failures to unqueried sources" + ) + assert metadata_task._provider_pause_seconds(failure.value) == 300 + + +@pytest.mark.parametrize( + "reason", + ["disabled", "unconfigured", "stale", "unmonitored", "fresh", "hydrating", "locg", "gcd"], +) +async def test_ineligible_series_never_contact_a_provider(scheduled, reason): + factory, adapter, scheduler = scheduled + series_id, data = await add_series( + factory, monitored=reason != "unmonitored", fresh=reason == "fresh" + ) + offer(adapter, data) + async with factory.begin() as session: + if reason in {"disabled", "unconfigured"}: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values( + **({"enabled": False} if reason == "disabled" else {"credential_secret": None}) + ) + ) + elif reason == "stale": + await session.execute(update(SeriesExternalIdentity).values(verification_state="stale")) + elif reason in {"locg", "gcd"}: + await session.execute( + update(SeriesExternalIdentity).values(identity_namespace=Namespace(reason)) + ) + if reason == "gcd": + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.GCD_API_V2.value) + .values(enabled=True, credential_secret=encrypt_secret("flagged-test-token")) + ) + adapter.source = Source.GCD_API_V2 + elif reason == "hydrating": + (await session.get(Series, series_id)).issue_catalog_state = IssueCatalogState.HYDRATING + await metadata_task.refresh_metadata() + assert adapter.calls == [] + scheduler._scheduler.add_job.assert_not_called() + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + + +@pytest.mark.parametrize("operation", ["series", "issues"]) +@pytest.mark.parametrize( + "status", + [ + SourceStatus.RATE_LIMITED, + SourceStatus.AUTHENTICATION_FAILED, + SourceStatus.TIMEOUT, + SourceStatus.UNAVAILABLE, + ], +) +async def test_scheduled_failure_is_atomic_and_stays_at_retry_cursor(scheduled, operation, status): + factory, adapter, scheduler = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + + async def throttled(*args, **kwargs): + adapter.calls.append(("throttled",)) + raise MetadataSourceError(status, retry_after_seconds=720) + + setattr(adapter, operation, throttled) + started = datetime.now(UTC).timestamp() + result = await metadata_task.refresh_metadata() + assert result.status == "waiting", "Structured source throttles must keep the sweep resumable" + async with factory() as session: + state = await load_sweep(session, "refresh_metadata") + assert state.active and state.cursor == 0 and state.upper_bound == series_id + assert started + 719 <= state.retry_at <= datetime.now(UTC).timestamp() + 721 + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + calls = list(adapter.calls) + await metadata_task.refresh_metadata() + assert adapter.calls == calls, "A durable cooldown must survive a new task invocation" + scheduler._scheduler.add_job.assert_not_called() + + +async def test_cancelling_refresh_keeps_uncommitted_cursor_and_resumes(scheduled): + factory, adapter, _ = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + adapter.wait = asyncio.Event() + task = asyncio.create_task(metadata_task.refresh_metadata()) + try: + done, _ = await asyncio.wait([task], timeout=0.2) + assert not done, ( + "The native refresh must reach its provider read rather than skip the series" + ) + await asyncio.wait_for(adapter.started.wait(), 3) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert task.cancelled() + async with factory() as session: + state = await load_sweep(session, "refresh_metadata") + assert state.active and state.cursor == 0 + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + adapter.wait = None + await metadata_task.refresh_metadata() + async with factory() as session: + state = await load_sweep(session, "refresh_metadata") + assert not state.active and state.cursor == series_id + + +async def test_structural_catalog_failure_does_not_search_or_change_owned_issues(scheduled): + factory, adapter, scheduler = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + adapter.data.issues[0].issue_number_text = "99" + result = await metadata_task.refresh_metadata() + assert adapter.calls, "A native series must be evaluated rather than silently skipped" + assert result.status == "completed" + scheduler._scheduler.add_job.assert_not_called() + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + assert (await session.scalar(select(Issue))).issue_number_text == "1" + assert (await load_sweep(session, "refresh_metadata")).cursor == series_id + + +async def test_disabling_last_source_clears_a_durable_cooldown(scheduled): + factory, adapter, scheduler = scheduled + series_id, _ = await add_series(factory) + async with factory.begin() as session: + await save_sweep( + session, + "refresh_metadata", + MetadataSweep( + upper_bound=series_id, + active=True, + retry_at=(datetime.now(UTC) + timedelta(hours=1)).timestamp(), + ), + ) + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + result = await metadata_task.refresh_metadata() + assert result.status == "completed" + assert adapter.calls == [] + async with factory() as session: + state = await load_sweep(session, "refresh_metadata") + assert not state.active and state.retry_at == 0 + scheduler.schedule_task_continuation.assert_not_called() + + +async def test_native_batch_preserves_initial_upper_bound_and_restarts(scheduled, monkeypatch): + factory, adapter, _ = scheduled + first, first_data = await add_series(factory) + second, second_data = await add_series(factory, identifier=43) + monkeypatch.setattr(metadata_task, "_METADATA_BATCH_SIZE", 1) + offer(adapter, first_data, count=1) + assert (await metadata_task.refresh_metadata()).status == "waiting" + third, _ = await add_series(factory, identifier=44) + async with factory() as session: + state = await load_sweep(session, "refresh_metadata") + assert state.cursor == first and state.upper_bound == second + offer(adapter, second_data, count=1) + assert (await metadata_task.refresh_metadata()).status == "completed" + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, first)).revision == 2 + assert (await load_metadata_baseline(session, Kind.SERIES, second)).revision == 2 + assert (await load_metadata_baseline(session, Kind.SERIES, third)).revision == 1 + + +async def test_failed_metadata_commit_never_fetches_artwork_or_schedules_search( + scheduled, monkeypatch +): + factory, adapter, scheduler = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + adapter.data.series.image_url = "https://static.metron.cloud/media/cover.jpg" + artwork = AsyncMock() + monkeypatch.setattr(metadata_task, "refresh_series_artwork", artwork) + original = metadata_task.save_sweep + failed = False + + async def fail_checkpoint(session, task_id, state): + nonlocal failed + if state.cursor == series_id and not failed: + failed = True + raise RuntimeError("checkpoint failed before commit") + await original(session, task_id, state) + + monkeypatch.setattr(metadata_task, "save_sweep", fail_checkpoint) + await metadata_task.refresh_metadata() + assert adapter.calls + artwork.assert_not_awaited() + scheduler._scheduler.add_job.assert_not_called() + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + + +async def test_search_is_committed_before_optional_artwork_wait(scheduled, monkeypatch): + factory, adapter, scheduler = scheduled + series_id, data = await add_series(factory) + offer(adapter, data) + adapter.data.series.image_url = "https://static.metron.cloud/media/cover.jpg" + + async def artwork(session, sid, url, covers): + assert sid == series_id + assert not session.in_transaction() + scheduler._scheduler.add_job.assert_called_once() + async with factory() as reader: + assert (await load_metadata_baseline(reader, Kind.SERIES, sid)).revision == 2 + assert (await load_sweep(reader, "refresh_metadata")).cursor == sid + raise asyncio.CancelledError + + monkeypatch.setattr(metadata_task, "refresh_series_artwork", artwork) + with pytest.raises(asyncio.CancelledError): + await metadata_task.refresh_metadata() + assert (await metadata_task.refresh_metadata()).status == "completed" + scheduler._scheduler.add_job.assert_called_once() + + +@pytest.mark.parametrize("source", [Source.COMICVINE_LOCAL, Source.COMICVINE_API]) +async def test_scheduled_comicvine_uses_shared_writer_and_configured_transport(scheduled, source): + factory, adapter, scheduler = scheduled + async with factory.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .values(enabled=True) + ) + if source is Source.COMICVINE_API: + await save_comicvine_api_key(session, "scheduled-test-cv-token") + series_id, data = await add_series(factory, source=source) + adapter.source = source + offer(adapter, data) + await metadata_task.refresh_metadata() + assert adapter.calls == [("series", "42"), ("issues", "42", 1)] + async with factory() as session: + series = await session.get(Series, series_id) + assert series.comicvine_id == 42 and series.issue_count == 2 + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 2 + assert ( + await session.scalar(select(Issue).where(Issue.issue_number_text == "50-X")) + ).comicvine_id == 4201 + scheduler._scheduler.add_job.assert_called_once() diff --git a/tests/tasks/test_metadata_search_trigger.py b/tests/tasks/test_metadata_search_trigger.py index bba4877d..df10a48b 100644 --- a/tests/tasks/test_metadata_search_trigger.py +++ b/tests/tasks/test_metadata_search_trigger.py @@ -17,6 +17,7 @@ import os import sys from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch @@ -26,6 +27,7 @@ from pullbox.models import Base from pullbox.models.issue import Issue, IssueStatus from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType +from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -288,6 +290,11 @@ def _sync_patches( scheduler: MagicMock, ) -> Generator[MagicMock, None, None]: """Context manager that patches all sync_new_issues dependencies.""" + + async def refresh(session, series_id): + await metadata_svc.refresh_series(session, series_id) + return ScheduledSeriesRefresh(0, False, None, Path("/unused-test-covers")) + with ( patch(f"{_MOD}.get_settings") as mock_settings, patch(f"{_MOD}.get_session_factory", return_value=db_factory), @@ -301,6 +308,11 @@ def _sync_patches( return_value=metadata_svc, ), patch(f"{_MOD}.get_scheduler", return_value=scheduler), + patch( + f"{_MOD}.scheduled_series_eligibility", + AsyncMock(return_value=Series.comicvine_id.isnot(None)), + ), + patch(f"{_MOD}.refresh_scheduled_series", side_effect=refresh), patch("pullbox.tasks.metadata_sweep_state.get_scheduler", return_value=scheduler), ): mock_settings.return_value = MagicMock() diff --git a/tests/tasks/test_scheduler_wrappers.py b/tests/tasks/test_scheduler_wrappers.py index f3e7aad7..9655112f 100644 --- a/tests/tasks/test_scheduler_wrappers.py +++ b/tests/tasks/test_scheduler_wrappers.py @@ -20,6 +20,7 @@ from pullbox.models.health import HealthCheckResult, HealthStatus from pullbox.models.issue import Issue, IssueStatus from pullbox.models.series import Series, SeriesStatus, SeriesType +from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh from pullbox.services.update_check import UpdateCheckResult from pullbox.tasks.backup_task import run_backups from pullbox.tasks.blocklist_task import expire_blocklist_entries @@ -376,27 +377,30 @@ async def test_refresh_metadata_commits_each_series_and_continues_after_failures failing_id = series_two.id fresh_id = fresh_series.id - async def _refresh_series(session: AsyncSession, series_id: int, **kwargs: object) -> None: + async def _refresh_series(session: AsyncSession, series_id: int) -> ScheduledSeriesRefresh: series = await session.get(Series, series_id) assert series is not None if series_id == failing_id: raise RuntimeError("boom") series.metadata_last_refreshed = refreshed_time + return ScheduledSeriesRefresh(0, False, None, Path("/unused-test-covers")) fake_service = SimpleNamespace(refresh_series=AsyncMock(side_effect=_refresh_series)) monkeypatch.setattr("pullbox.tasks.metadata_task.get_session_factory", lambda: db_factory) monkeypatch.setattr( "pullbox.tasks.metadata_task.get_settings", - lambda: SimpleNamespace(metadata_refresh_days=7, comicvine_rate_limit=1), + lambda: SimpleNamespace( + metadata_refresh_days=7, comicvine_rate_limit=1, metadata_gcd_api_v2_enabled=False + ), ) monkeypatch.setattr( - "pullbox.tasks.metadata_task.get_comicvine_api_key", - AsyncMock(return_value="cv-key"), + "pullbox.tasks.metadata_task.scheduled_series_eligibility", + AsyncMock(return_value=Series.comicvine_id.isnot(None)), ) monkeypatch.setattr( - "pullbox.tasks.metadata_task._create_metadata_service", - AsyncMock(return_value=fake_service), + "pullbox.tasks.metadata_task.refresh_scheduled_series", + fake_service.refresh_series, ) await refresh_metadata() From a6325b49ba76b02ff6226b5019693ff0fe765db1 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 14:39:37 -0700 Subject: [PATCH 47/97] feat(metadata): add bounded recent issue source reads --- docs/development/ARCHITECTURE_OVERVIEW.md | 10 + src/pullbox/providers/metadata/comicvine.py | 6 +- src/pullbox/providers/metadata/metron.py | 28 +- src/pullbox/providers/metadata/sources.py | 55 ++- src/pullbox/schemas/metadata_sources.py | 19 + src/pullbox/services/catalog/reader.py | 23 ++ src/pullbox/services/metadata_discovery.py | 9 + src/pullbox/services/metadata_source_reads.py | 68 ++++ tests/unit/test_metadata_recent_issues.py | 336 ++++++++++++++++++ 9 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_metadata_recent_issues.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 799c6351..709235f3 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -326,6 +326,16 @@ authentication and the existing session CSRF protection. Source namespace, entity identity, issue parent, page completeness and continuation are checked before returning data. Preview is read-only and is not an adoption proof. +The separate `recent_issues` capability returns at most 100 issues and never +claims complete membership. ComicVine local/API return newest-publication slices; +Metron returns issues modified after an explicit UTC checkpoint. Query totals +and truncation describe only that slice. A truncated modification window needs +a full/bounded reconciliation before advancing its checkpoint. Empty local +results still carry their immutable catalog generation. These reads do not use +the full-page response cache, follow continuation links or attach identities. +Daily issue-sweep integration remains pending; existing cadence/full-sync rules +must be retained when that caller adopts this contract. + `services/metadata_series_adoption.py` separates a complete server-side catalog fetch from transactional adoption. Traversal rejects partial pages, repeated identities, changed counts and mixed local catalog generations. The writer locks diff --git a/src/pullbox/providers/metadata/comicvine.py b/src/pullbox/providers/metadata/comicvine.py index 5ffd3064..8632500a 100644 --- a/src/pullbox/providers/metadata/comicvine.py +++ b/src/pullbox/providers/metadata/comicvine.py @@ -936,13 +936,15 @@ async def get_issue(self, provider_id: str, *, strict_response: bool = False) -> return _issue_metadata_from_item(item, fallback_provider_id=provider_id) async def get_issues_page( - self, series_provider_id: str, *, page: int = 1 + self, series_provider_id: str, *, page: int = 1, newest_first: bool = False ) -> tuple[list[IssueMetadata], int]: """Read one complete page with explicit parent identity, without auto-pagination.""" if re.fullmatch(r"[1-9][0-9]{0,18}", series_provider_id) is None: raise ValueError("ComicVine provider IDs must be positive integers") if type(page) is not int or not 1 <= page <= 10000: raise ValueError("Invalid ComicVine page") + if type(newest_first) is not bool or (newest_first and page != 1): + raise ValueError("Recent ComicVine reads are limited to one page") offset = (page - 1) * 100 data = await self._request( "/issues/", @@ -951,7 +953,7 @@ async def get_issues_page( "field_list": ( "id,volume,issue_number,name,cover_date,store_date,image,site_detail_url" ), - "sort": "id:asc", + "sort": "store_date:desc" if newest_first else "id:asc", "limit": 100, "offset": offset, }, diff --git a/src/pullbox/providers/metadata/metron.py b/src/pullbox/providers/metadata/metron.py index bbc02bf4..ffcc202c 100644 --- a/src/pullbox/providers/metadata/metron.py +++ b/src/pullbox/providers/metadata/metron.py @@ -14,7 +14,7 @@ import httpx from pullbox import __version__ -from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource from pullbox.core.provider_cooldown import ProviderCooldown, provider_cooldown, retry_after_seconds from pullbox.providers.metadata import metron_normalization as normalize from pullbox.schemas.metadata_sources import ( @@ -23,12 +23,15 @@ ProviderIssueRead, ProviderSeriesRead, ProviderStoryArcRead, + RecentIssueWindow, SourceStatus, ) from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage +from pullbox.services.metadata_source_reads import issue_checkpoint, validate_recent_issue_window if TYPE_CHECKING: from collections.abc import Callable + from datetime import datetime from pydantic import SecretStr @@ -319,6 +322,29 @@ async def _detail[T: (ProviderSeriesRead, ProviderIssueRead, ProviderStoryArcRea except (ValueError, TypeError): raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + async def recent_issues( + self, external_id: str, *, since: datetime + ) -> MetadataFetch[RecentIssueWindow]: + identifier = normalize.external_id(external_id) + checkpoint = issue_checkpoint(since) + params = {"series_id": identifier, "modified_gt": checkpoint.isoformat(), "page": "1"} + response = await self._get("issue/", params) + if response.status is not SourceStatus.OK: + return MetadataFetch(status=response.status) + try: + rows, total, next_page = _envelope(response.payload, "issue/", params) + result = RecentIssueWindow( + results=[normalize.issue(row) for row in rows], + matched_total=total, + scope="modified_since", + since=checkpoint, + truncated=next_page is not None, + ) + validate_recent_issue_window(result, MetadataSource.METRON_API, identifier, checkpoint) + return MetadataFetch(status=SourceStatus.OK, data=result) + except (ValueError, TypeError): + raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None + async def series( self, external_id: str, *, validator: str | None = None ) -> MetadataFetch[ProviderSeriesRead]: diff --git a/src/pullbox/providers/metadata/sources.py b/src/pullbox/providers/metadata/sources.py index 425274bd..2efd2a4e 100644 --- a/src/pullbox/providers/metadata/sources.py +++ b/src/pullbox/providers/metadata/sources.py @@ -17,6 +17,7 @@ ProviderIssueRead, ProviderSeriesRead, ProviderStoryArcRead, + RecentIssueWindow, SeriesDiscoveryQuery, SourceCapability, SourceStatus, @@ -25,10 +26,16 @@ from pullbox.services.catalog.reader import get_catalog_reader from pullbox.services.catalog.storage import disk_work from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage, SourceRegistration -from pullbox.services.metadata_source_reads import page_number, source_id +from pullbox.services.metadata_source_reads import ( + issue_checkpoint, + page_number, + source_id, + validate_recent_issue_window, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable + from datetime import datetime from pullbox.providers.base import SeriesSearchResult from pullbox.providers.story_arcs import StoryArcMetadata, StoryArcSearchResult @@ -174,6 +181,27 @@ class ComicVineApiSource: def __init__(self, provider: ComicVineProvider) -> None: self.provider = provider + async def recent_issues( + self, external_id: str, *, since: datetime + ) -> MetadataFetch[RecentIssueWindow]: + identifier = _cv_id(external_id, MetadataEntityKind.SERIES) + checkpoint = issue_checkpoint(since) + + async def read() -> RecentIssueWindow: + rows, total = await self.provider.get_issues_page(identifier, newest_first=True) + result = RecentIssueWindow( + results=[normalize.issue(MetadataSource.COMICVINE_API, row) for row in rows], + matched_total=total, + scope="recent_publication", + truncated=total > 100, + ) + validate_recent_issue_window( + result, MetadataSource.COMICVINE_API, identifier, checkpoint + ) + return result + + return await _fetch(read) + async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: try: if query.search_mode == "full": @@ -325,6 +353,29 @@ class ComicVineLocalSource: def __init__(self, reader: CatalogReader) -> None: self.reader = reader + async def recent_issues( + self, external_id: str, *, since: datetime + ) -> MetadataFetch[RecentIssueWindow]: + identifier = _cv_id(external_id, MetadataEntityKind.SERIES) + checkpoint = issue_checkpoint(since) + + async def read() -> RecentIssueWindow: + await self._available() + rows, total, cutoff = await self.reader.recent_issues(int(identifier)) + result = RecentIssueWindow( + results=[normalize.issue(MetadataSource.COMICVINE_LOCAL, row) for row in rows], + matched_total=total, + scope="recent_publication", + truncated=total > 100, + source_updated_at=cutoff, + ) + validate_recent_issue_window( + result, MetadataSource.COMICVINE_LOCAL, identifier, checkpoint + ) + return result + + return await _fetch(lambda: _catalog_read(read)) + async def _available(self) -> None: if not await disk_work(lambda: self.reader.available): raise MetadataSourceError(SourceStatus.UNCONFIGURED) @@ -418,6 +469,7 @@ def comicvine_sources() -> dict[MetadataSource, SourceRegistration]: SourceCapability.SERIES_SEARCH, SourceCapability.SERIES_DETAILS, SourceCapability.ISSUE_LIST, + SourceCapability.RECENT_ISSUES, SourceCapability.ISSUE_DETAILS, } return { @@ -454,6 +506,7 @@ def metadata_sources() -> dict[MetadataSource, SourceRegistration]: SourceCapability.SERIES_SEARCH, SourceCapability.SERIES_DETAILS, SourceCapability.ISSUE_LIST, + SourceCapability.RECENT_ISSUES, SourceCapability.ISSUE_DETAILS, SourceCapability.STORY_ARC_SEARCH, SourceCapability.STORY_ARC_DETAILS, diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index 7961a001..24444d41 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -25,6 +25,7 @@ class SourceCapability(enum.StrEnum): SERIES_SEARCH = "series_search" SERIES_DETAILS = "series_details" ISSUE_LIST = "issue_list" + RECENT_ISSUES = "recent_issues" ISSUE_DETAILS = "issue_details" CROSS_IDENTITIES = "cross_identities" CONDITIONAL_REFRESH = "conditional_refresh" @@ -229,6 +230,24 @@ class MetadataFetch[T](BaseModel): retry_after_seconds: int | None = None +class RecentIssueWindow(BaseModel): + """One bounded slice, never evidence of complete series membership.""" + + model_config = ConfigDict(extra="forbid") + results: list[ProviderIssueRead] = Field(max_length=100) + matched_total: int = Field( + ge=0, + le=1_000_000_000, + strict=True, + description="Count matching this query, not proof of complete series membership.", + ) + scope: Literal["recent_publication", "modified_since"] + since: datetime | None = None + truncated: bool = Field(strict=True) + full_catalog: Literal[False] = False + source_updated_at: datetime | None = None + + class SeriesPreviewQuery(BaseModel): model_config = ConfigDict(extra="forbid") entity_kind: ClassVar[MetadataEntityKind] = MetadataEntityKind.SERIES diff --git a/src/pullbox/services/catalog/reader.py b/src/pullbox/services/catalog/reader.py index 056c6c61..3f1104c1 100644 --- a/src/pullbox/services/catalog/reader.py +++ b/src/pullbox/services/catalog/reader.py @@ -212,6 +212,29 @@ def _issue_metadata( source_cutoff_at=cutoff, ) + async def recent_issues(self, series_id: int) -> tuple[list[IssueMetadata], int, datetime]: + """Newest publication slice and total, pinned to one immutable generation.""" + if type(series_id) is not int or not 0 < series_id < 2**63: + raise ValueError("Invalid catalog series identity") + rows, cutoff = await disk_work( + self._query, + f"WITH page AS (SELECT {ISSUE_COLUMNS} FROM issues WHERE series_id=? " + "ORDER BY store_date DESC,id DESC LIMIT 100), " + "tally AS (SELECT COUNT(*) AS total FROM issues WHERE series_id=?) " + "SELECT page.*,tally.total FROM tally LEFT JOIN page ON 1=1 " + "ORDER BY page.store_date DESC,page.id DESC", + (series_id, series_id), + ) + return ( + [ + self._issue_metadata(row, cutoff, preserve_number_text=True) + for row in rows + if row[0] is not None + ], + int(rows[0][-1]), + cutoff, + ) + async def issue_page(self, series_id: int, *, page: int = 1) -> tuple[list[IssueMetadata], int]: """Count and read a bounded page in one query on one immutable generation.""" if type(page) is not int or not 1 <= page <= 10000: diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index f84931a4..b5f5bf91 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -16,6 +16,7 @@ ProviderIssueRead, ProviderSeriesRead, ProviderStoryArcRead, + RecentIssueWindow, SeriesDiscoveryQuery, SeriesDiscoveryRead, SourceCapability, @@ -30,6 +31,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence + from datetime import datetime from pullbox.services.metadata_read_cache import MetadataReadCache from pullbox.services.metadata_sources import SourceRuntime @@ -436,6 +438,13 @@ async def issues( return await read_issues(self, source, external_id, page=page, validator=validator) + async def recent_issues( + self, source: MetadataSource, external_id: str, *, since: datetime + ) -> MetadataFetch[RecentIssueWindow]: + from pullbox.services.metadata_source_reads import read_recent_issues + + return await read_recent_issues(self, source, external_id, since=since) + async def story_arc( self, source: MetadataSource, external_id: str, *, validator: str | None = None ) -> MetadataFetch[ProviderStoryArcRead]: diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py index 3dd2f655..d21ae9ef 100644 --- a/src/pullbox/services/metadata_source_reads.py +++ b/src/pullbox/services/metadata_source_reads.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime from typing import TYPE_CHECKING, Protocol, runtime_checkable import structlog @@ -14,6 +15,7 @@ ProviderIssueRead, ProviderSeriesRead, ProviderStoryArcRead, + RecentIssueWindow, SourceCapability, SourceStatus, ) @@ -50,6 +52,13 @@ async def issues( ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: ... +@runtime_checkable +class RecentIssueAdapter(Protocol): + async def recent_issues( + self, external_id: str, *, since: datetime + ) -> MetadataFetch[RecentIssueWindow]: ... + + @runtime_checkable class StoryArcDetailAdapter(Protocol): async def story_arc( @@ -82,6 +91,12 @@ def page_number(page: int) -> None: raise ValueError("Metadata page must be between one and ten thousand") +def issue_checkpoint(since: datetime) -> datetime: + if not isinstance(since, datetime) or since.utcoffset() is None: + raise ValueError("Issue checkpoint must include a timezone") + return since.astimezone(UTC) + + def _identity( row: ProviderSeriesRead | ProviderIssueRead | ProviderStoryArcRead, source: MetadataSource, @@ -271,6 +286,59 @@ def validate(row: ProviderIssueRead) -> None: ) +def validate_recent_issue_window( + result: RecentIssueWindow, source: MetadataSource, identifier: str, since: datetime +) -> None: + # Revalidate even constructed/copied DTOs at the adapter trust boundary. + checked = RecentIssueWindow.model_validate(result.model_dump()) + if ( + len(checked.results) != min(100, checked.matched_total) + or checked.truncated != (checked.matched_total > 100) + or len({row.external_id for row in checked.results}) != len(checked.results) + ): + raise ValueError("Incomplete recent issue window") + if checked.scope == "modified_since": + if checked.since is None or issue_checkpoint(checked.since) != since: + raise ValueError("Different issue checkpoint") + elif checked.since is not None: + raise ValueError("Publication slice cannot claim a modification checkpoint") + local = source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL} + if local: + if checked.source_updated_at is None: + raise ValueError("Missing catalog generation") + issue_checkpoint(checked.source_updated_at) + for row in checked.results: + _issue(row, source) + if row.series_external_id != identifier: + raise ValueError("Issue belongs to a different series") + if checked.scope == "modified_since" and ( + row.source_updated_at is None or issue_checkpoint(row.source_updated_at) <= since + ): + raise ValueError("Issue is outside the requested modification window") + if local and row.source_updated_at != checked.source_updated_at: + raise ValueError("Catalog generation changed during the read") + + +async def read_recent_issues( + registry: MetadataSourceRegistry, source: MetadataSource, external_id: str, *, since: datetime +) -> MetadataFetch[RecentIssueWindow]: + identifier = source_id(source, MetadataEntityKind.SERIES, external_id) + checkpoint = issue_checkpoint(since) + + async def operation( + adapter: MetadataSourceAdapter, _validator: str | None + ) -> MetadataFetch[RecentIssueWindow]: + if not isinstance(adapter, RecentIssueAdapter): + raise MetadataSourceError(SourceStatus.UNSUPPORTED) + return await adapter.recent_issues(identifier, since=checkpoint) + + def validate(result: RecentIssueWindow) -> None: + validate_recent_issue_window(result, source, identifier, checkpoint) + + # A checkpoint-specific slice must not reuse a full-catalog page cache entry. + return await _read(registry, source, SourceCapability.RECENT_ISSUES, operation, validate, None) + + async def read_issues( registry: MetadataSourceRegistry, source: MetadataSource, diff --git a/tests/unit/test_metadata_recent_issues.py b/tests/unit/test_metadata_recent_issues.py new file mode 100644 index 00000000..febfb3c4 --- /dev/null +++ b/tests/unit/test_metadata_recent_issues.py @@ -0,0 +1,336 @@ +"""Recent windows cannot masquerade as complete or cross-series issue catalogs.""" + +import asyncio +import json +import sqlite3 +from datetime import UTC, datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import httpx +import pytest + +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.providers.metadata.sources import ComicVineLocalSource, metadata_sources +from pullbox.schemas.metadata_sources import ( + MetadataFetch, + RecentIssueWindow, + SourceCapability, + SourceStatus, +) +from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry +from tests.catalog_fixtures import TABLES, content_hash +from tests.unit.test_catalog_reader import installed_reader +from tests.unit.test_metadata_discovery import registration, runtime +from tests.unit.test_metadata_source_adapters import api_adapter +from tests.unit.test_metadata_source_reads import ReadAdapter, issue_row, registry +from tests.unit.test_metron_source import envelope, source +from tests.unit.test_metron_source import issue_row as metron_issue + +SINCE = datetime(2026, 9, 15, tzinfo=UTC) + + +def window(**changes): + return RecentIssueWindow( + results=[issue_row(source_updated_at=SINCE + timedelta(days=1))], + matched_total=1, + scope="modified_since", + since=SINCE, + truncated=False, + ).model_copy(update=changes) + + +class RecentAdapter(ReadAdapter): + async def recent_issues(self, external_id, *, since): + self.calls.append((external_id, since)) + self.started.set() + if self.wait: + await self.wait.wait() + if self.error: + raise self.error + return self.result or MetadataFetch(status=SourceStatus.OK, data=window()) + + +async def test_registry_preserves_bounded_window_without_claiming_full_membership(): + adapter = RecentAdapter() + result = await registry(adapter).recent_issues(adapter.source, "00042", since=SINCE) + assert result.status is SourceStatus.OK + assert result.data.full_catalog is False + assert result.data.results[0].issue_number_text == "50-x" + assert adapter.calls == [("42", SINCE)] and adapter.closed == 1 + + +@pytest.mark.parametrize( + "changes", + [ + {"results": [issue_row(series_external_id="43")]}, + {"results": [issue_row(), issue_row()], "matched_total": 2}, + {"matched_total": True}, + {"matched_total": 2}, + {"truncated": True}, + {"full_catalog": True}, + {"since": SINCE - timedelta(days=1)}, + {"results": [issue_row(source_updated_at=SINCE)]}, + {"results": [issue_row(source=Source.COMICVINE_API)]}, + ], +) +async def test_registry_rejects_wrong_parent_incomplete_or_different_window(changes): + adapter = RecentAdapter(result=MetadataFetch(status=SourceStatus.OK, data=window(**changes))) + result = await registry(adapter).recent_issues(adapter.source, "42", since=SINCE) + assert result.status is SourceStatus.INCOMPATIBLE_RESPONSE and result.data is None + assert adapter.closed == 1 + + +@pytest.mark.parametrize("since", [datetime(2026, 9, 15), "2026-09-15", None]) +async def test_invalid_checkpoint_never_constructs_adapter(since): + adapter = RecentAdapter() + with pytest.raises(ValueError): + await registry(adapter).recent_issues(adapter.source, "42", since=since) + assert not adapter.calls and adapter.closed == 0 + + +async def test_disabled_and_unadvertised_recent_reads_never_contact_provider(): + adapter = RecentAdapter() + for enabled, capabilities, expected in ( + (False, list(SourceCapability), SourceStatus.DISABLED), + (True, [SourceCapability.ISSUE_LIST], SourceStatus.UNSUPPORTED), + ): + instance = MetadataSourceRegistry( + [runtime(adapter.source, enabled=enabled)], + factories={adapter.source: registration(adapter, capabilities=capabilities)}, + ) + assert (await instance.recent_issues(adapter.source, "42", since=SINCE)).status is expected + assert not adapter.calls and adapter.closed == 0 + + +async def test_recent_timeout_cancel_and_retry_after_keep_owned_client_lifecycle(): + adapter = RecentAdapter(error=MetadataSourceError(SourceStatus.RATE_LIMITED, 720)) + result = await registry(adapter).recent_issues(adapter.source, "42", since=SINCE) + assert result.status is SourceStatus.RATE_LIMITED and result.retry_after_seconds == 720 + assert adapter.closed == 1 + adapter = RecentAdapter(wait=asyncio.Event()) + result = await registry(adapter, per_source_timeout=0.01).recent_issues( + adapter.source, "42", since=SINCE + ) + assert result.status is SourceStatus.TIMEOUT and adapter.closed == 1 + task = asyncio.create_task(registry(adapter).recent_issues(adapter.source, "42", since=SINCE)) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert adapter.closed == 2 + + +@pytest.mark.parametrize("total", [0, 1, 100, 101]) +async def test_metron_modified_window_is_one_filtered_request_even_when_overflowing(total): + calls = [] + params = {"series_id": "8", "modified_gt": SINCE.isoformat(), "page": "1"} + + def handle(request): + calls.append(request) + next_url = str(request.url.copy_set_param("page", "2")) if total > 100 else None + return httpx.Response( + 200, + json=envelope( + [metron_issue(i) for i in range(1, min(total, 100) + 1)], + count=total, + next_url=next_url, + ), + ) + + adapter = source(handle) + try: + result = await adapter.recent_issues("8", since=SINCE) + assert result.status is SourceStatus.OK + assert len(calls) == 1 and calls[0].url.path == "/api/issue/" + assert dict(calls[0].url.params) == params + assert result.data.matched_total == total + assert result.data.scope == "modified_since" and result.data.since == SINCE + assert result.data.truncated is (total > 100) and not result.data.full_catalog + assert len(result.data.results) == min(total, 100) + finally: + await adapter.close() + + +@pytest.mark.parametrize("fault", ["parent", "timestamp", "continuation"]) +async def test_metron_window_rejects_filter_or_continuation_disagreement(fault): + def handle(request): + rows = [metron_issue(i) for i in range(1, 101)] + link = str(request.url.copy_set_param("page", "2")) + if fault == "parent": + rows[0]["series"]["id"] = 9 + elif fault == "timestamp": + rows[0]["modified"] = SINCE.isoformat() + else: + link = str(request.url.copy_set_param("page", "2").copy_set_param("series_id", "9")) + return httpx.Response(200, json=envelope(rows, count=101, next_url=link)) + + adapter = source(handle) + try: + with pytest.raises(MetadataSourceError) as error: + await adapter.recent_issues("8", since=SINCE) + assert error.value.status is SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await adapter.close() + + +@pytest.mark.parametrize("total", [0, 1, 100, 101]) +async def test_comicvine_window_retains_one_request_and_explicit_parent(total): + calls = [] + + def handle(request): + calls.append(request) + return httpx.Response( + 200, + json={ + "status_code": 1, + "number_of_total_results": total, + "results": [ + {"id": i, "volume": {"id": 42}, "issue_number": "50-x"} + for i in range(1, min(total, 100) + 1) + ], + }, + ) + + adapter = await api_adapter(handle) + try: + result = await adapter.recent_issues("42", since=SINCE) + assert result.status is SourceStatus.OK + assert len(calls) == 1 and calls[0].url.params["sort"] == "store_date:desc" + assert calls[0].url.params["filter"] == "volume:42" + assert calls[0].url.params["limit"] == "100" and calls[0].url.params["offset"] == "0" + assert "volume" in calls[0].url.params["field_list"].split(",") + assert result.data.scope == "recent_publication" and result.data.since is None + assert result.data.matched_total == total and result.data.truncated is (total > 100) + assert not result.data.full_catalog + finally: + await adapter.close() + + +@pytest.mark.parametrize("identifier", [10, 999]) +async def test_local_window_retains_generation_including_empty_result(tmp_path, identifier): + reader = installed_reader(tmp_path) + adapter = ComicVineLocalSource(reader) + result = await adapter.recent_issues(str(identifier), since=SINCE) + assert result.status is SourceStatus.OK + assert result.data.matched_total == (1 if identifier == 10 else 0) + assert result.data.source_updated_at == datetime(2026, 9, 13, 5, tzinfo=UTC) + assert result.data.scope == "recent_publication" and not result.data.full_catalog + assert all( + row.source_updated_at == result.data.source_updated_at for row in result.data.results + ) + + +async def test_registry_normalizes_checkpoint_timezone(): + adapter = RecentAdapter() + await registry(adapter).recent_issues( + adapter.source, "42", since=SINCE.astimezone(timezone(timedelta(hours=-7))) + ) + assert adapter.calls == [("42", SINCE)] + assert adapter.calls[0][1].tzinfo is UTC + + +def test_executable_sources_advertise_recent_issue_capability(): + for slug in (Source.COMICVINE_API, Source.COMICVINE_LOCAL, Source.METRON_API): + assert SourceCapability.RECENT_ISSUES in metadata_sources()[slug].capabilities + + +async def test_recent_read_does_not_reuse_page_cache_or_accept_unsolicited_not_modified(): + adapter = RecentAdapter() + cache = AsyncMock() + result = await registry(adapter, read_cache=cache).recent_issues( + adapter.source, "42", since=SINCE + ) + assert result.status is SourceStatus.OK + cache.get.assert_not_called() + adapter.result = MetadataFetch(status=SourceStatus.NOT_MODIFIED) + result = await registry(adapter).recent_issues(adapter.source, "42", since=SINCE) + assert result.status is SourceStatus.INCOMPATIBLE_RESPONSE + + +async def test_recent_gcd_flag_and_missing_local_catalog_remain_fail_closed(tmp_path): + instance = MetadataSourceRegistry([runtime(Source.GCD_API_V2)]) + result = await instance.recent_issues(Source.GCD_API_V2, "42", since=SINCE) + assert result.status is SourceStatus.FEATURE_DISABLED + reader = installed_reader(tmp_path) + (reader.root / "active.json").unlink() + with pytest.raises(MetadataSourceError) as error: + await ComicVineLocalSource(reader).recent_issues("10", since=SINCE) + assert error.value.status is SourceStatus.UNCONFIGURED + + +@pytest.mark.parametrize("fault", ["parent", "missing_parent", "duplicate", "missing_row"]) +async def test_comicvine_window_rejects_wrong_membership_without_partial_success(fault): + rows = [{"id": i, "volume": {"id": 42}, "issue_number": str(i)} for i in (1, 2)] + if fault == "parent": + rows[0]["volume"]["id"] = 43 + elif fault == "missing_parent": + del rows[0]["volume"] + elif fault == "duplicate": + rows[1]["id"] = 1 + else: + rows.pop() + adapter = await api_adapter( + lambda request: httpx.Response( + 200, + json={ + "status_code": 1, + "number_of_total_results": 2, + "results": rows, + }, + ) + ) + try: + with pytest.raises(MetadataSourceError) as error: + await adapter.recent_issues("42", since=SINCE) + assert error.value.status is SourceStatus.INCOMPATIBLE_RESPONSE + finally: + await adapter.close() + + +async def test_local_window_sorts_and_limits_real_rows_without_including_other_series(tmp_path): + reader = installed_reader(tmp_path) + with sqlite3.connect(reader.root / "bases/20260913T050000Z.db") as db: + db.execute("DELETE FROM issues") + db.execute("INSERT INTO series VALUES (11,'Other',2024,1,1,NULL)") + db.execute("INSERT INTO series_fts(rowid,series_id,name,aliases) VALUES (11,11,'Other','')") + db.executemany( + "INSERT INTO issues VALUES (?,?,?,?,?,?,?,?,?)", + [ + (i, 10, str(i), str(i), str(i), None, None, "2026-09-01" if i < 120 else None, None) + for i in range(1, 131) + ] + + [(999, 11, "1", "1", "1", None, None, "2027-01-01", None)], + ) + # Deliberately give a small ID the newest publication date. + db.execute("UPDATE issues SET store_date='2026-09-15' WHERE id=1") + db.execute( + "UPDATE dataset_manifest SET value=? WHERE key='counts'", + ( + json.dumps( + { + table: db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + for table in TABLES + } + ), + ), + ) + db.execute( + "UPDATE dataset_manifest SET value=? WHERE key='content_sha256'", + (json.dumps(content_hash(db)),), + ) + statements = [] + original = reader._query + + def traced(sql, params): + statements.append((sql, params)) + return original(sql, params) + + reader._query = traced + result = await ComicVineLocalSource(reader).recent_issues("10", since=SINCE) + assert result.status is SourceStatus.OK + assert result.data.matched_total == 130 and result.data.truncated + assert [row.external_id for row in result.data.results] == ["1", *map(str, range(119, 20, -1))] + assert all( + row.source_updated_at == result.data.source_updated_at for row in result.data.results + ) + assert len(statements) == 1, "Total, rows and generation must come from one bounded read" From af4f6fc9404dd9759ef431563640b90add08b3ec Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 15:11:32 -0700 Subject: [PATCH 48/97] feat(metadata): persist source-bound catalog checkpoints --- .../u2o3p4q5r678_add_catalog_checkpoints.py | 69 +++ docs/development/ARCHITECTURE_OVERVIEW.md | 14 +- docs/development/DATABASE_STANDARDS.md | 18 + src/pullbox/models/__init__.py | 2 + .../models/metadata_catalog_checkpoint.py | 44 ++ .../services/metadata_catalog_checkpoints.py | 205 +++++++ .../services/metadata_series_adoption.py | 41 +- .../services/metadata_series_refresh.py | 23 + .../services/metadata_series_refresh_state.py | 9 +- .../test_catalog_checkpoints.py | 527 ++++++++++++++++++ .../test_production_migration.py | 1 + .../test_source_configuration.py | 3 + tests/integration/test_migrations.py | 3 +- 13 files changed, 955 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py create mode 100644 src/pullbox/models/metadata_catalog_checkpoint.py create mode 100644 src/pullbox/services/metadata_catalog_checkpoints.py create mode 100644 tests/integration/metadata_identity/test_catalog_checkpoints.py diff --git a/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py b/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py new file mode 100644 index 00000000..45bac92b --- /dev/null +++ b/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py @@ -0,0 +1,69 @@ +"""Keep full-catalog checkpoints bound to their source and verified identity.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "u2o3p4q5r678" +down_revision = "t1n2o3p4q567" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Existing global timestamps do not prove which provider supplied a catalog. + op.create_table( + "series_catalog_checkpoints", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "series_id", + sa.Integer(), + sa.ForeignKey("series.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "source", + sa.String(30), + sa.ForeignKey("metadata_source_configs.source", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("source_revision", sa.Integer(), nullable=False), + sa.Column( + "identity_id", + sa.Integer(), + sa.ForeignKey("series_external_identities.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("identity_revision", sa.Integer(), nullable=False), + sa.Column("external_id", sa.String(255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("full_synced_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("source_updated_at", sa.DateTime(timezone=True)), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("series_id", "source", name="uq_catalog_checkpoint_series_source"), + sa.CheckConstraint( + "revision > 0 AND source_revision > 0 AND identity_revision > 0", + name="ck_catalog_checkpoint_revisions", + ), + sa.CheckConstraint( + "length(external_id) BETWEEN 1 AND 255", name="ck_catalog_checkpoint_id" + ), + sa.CheckConstraint("full_synced_at <= checked_at", name="ck_catalog_checkpoint_time"), + sa.CheckConstraint( + "source NOT IN ('comicvine_local', 'gcd_local') OR source_updated_at IS NOT NULL", + name="ck_catalog_checkpoint_generation", + ), + ) + op.create_index("ix_catalog_checkpoint_source", "series_catalog_checkpoints", ["source"]) + op.create_index("ix_catalog_checkpoint_identity", "series_catalog_checkpoints", ["identity_id"]) + + +def downgrade() -> None: + # Discard only sync progress, never canonical metadata, files or ownership. + op.drop_table("series_catalog_checkpoints") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 709235f3..406ebe78 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -336,6 +336,16 @@ the full-page response cache, follow continuation links or attach identities. Daily issue-sweep integration remains pending; existing cadence/full-sync rules must be retained when that caller adopts this contract. +Complete source-aware Add and refresh now persist source-bound catalog +checkpoints with the same atomic library write. The checkpoint retains the +source-policy and verified-identity revisions, exact parent and local generation. +Only proven live catalog reads establish the request-start boundary. Cached +bundles of unknown age and Add of an existing series do not advance progress. +Refresh revalidates captured checkpoints along with metadata and policies, so +an in-flight refresh cannot overwrite newer sync progress. Incremental consumers +must still handle overlap, generation changes and truncated modification windows; +this checkpoint storage does not itself implement daily source-aware sync. + `services/metadata_series_adoption.py` separates a complete server-side catalog fetch from transactional adoption. Traversal rejects partial pages, repeated identities, changed counts and mixed local catalog generations. The writer locks @@ -353,7 +363,9 @@ Normalized remote detail and issue-page reads reuse `metadata_provider_cache` through `services/metadata_read_cache.py`. Keys bind source, policy revision, operation, external ID and page; credentials are never cache key material. Responses are fresh for five minutes (not-found for 15 seconds), with validators -retained for one day. Add and refresh commands always revalidate. A not-modified +retained for one day. Add and refresh commands always revalidate. Complete series +catalog reads that establish sync checkpoints bypass this cache and its shared +flights; descriptive reads and other consumers retain the existing cache. A not-modified response requires an exact saved response; outages never become cached success. The cache is limited to 128 entries of at most 256 KiB each, separate from legacy ComicVine import entries. It owns short database sessions outside provider I/O, diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 61edce7a..369ec873 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -423,6 +423,24 @@ catches the conflict. Future refresh writers must additionally revalidate their captured source-policy and ownership revisions and current user values before applying network results; the baseline service is not a substitute for that check. +### Source Catalog Checkpoints + +`series_catalog_checkpoints` records complete issue-catalog progress separately +for each series/source pair. Real cascading foreign keys bind it to the series, +source configuration and active identity row. The saved source revision, identity +revision and exact external ID must still match before a cursor is usable. A +local-source checkpoint also retains the catalog generation; incremental readers +must compare that generation before applying a partial window. + +Full Add/refresh writes persist the checkpoint in the same caller-owned +transaction as catalog rows and canonical baselines. A captured checkpoint +revision participates in refresh revalidation. Checkpoint timestamps record +the live request start, never completion or an unvalidated cache hit. Explicit +full revalidation bypasses response-cache coalescing for that catalog so a +joined earlier fetch cannot be mistaken for a new modification boundary. +Migration leaves existing global timestamps untouched and creates no synthetic +provider checkpoints. Downgrade discards sync progress only. + ### 4.1 Base Model Mixins **Current Pullbox implementation** diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index 29404944..e1fd14ea 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -65,6 +65,7 @@ SeriesMetadataBaseline, StoryArcMetadataBaseline, ) +from pullbox.models.metadata_catalog_checkpoint import SeriesCatalogCheckpoint from pullbox.models.metadata_identity import ( IssueExternalIdentity, IssueIdentityEvent, @@ -213,6 +214,7 @@ "SearchLog", "SearchType", "Series", + "SeriesCatalogCheckpoint", "SeriesExternalIdentity", "SeriesIdentityEvent", "SeriesMetadataBaseline", diff --git a/src/pullbox/models/metadata_catalog_checkpoint.py b/src/pullbox/models/metadata_catalog_checkpoint.py new file mode 100644 index 00000000..6af5a6d8 --- /dev/null +++ b/src/pullbox/models/metadata_catalog_checkpoint.py @@ -0,0 +1,44 @@ +"""Source-specific catalog progress; not metadata identity or provenance.""" + +from datetime import datetime + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime + + +class SeriesCatalogCheckpoint(Base, IdentityMixin, TimestampMixin): + __tablename__ = "series_catalog_checkpoints" + __table_args__ = ( + UniqueConstraint("series_id", "source", name="uq_catalog_checkpoint_series_source"), + CheckConstraint( + "revision > 0 AND source_revision > 0 AND identity_revision > 0", + name="ck_catalog_checkpoint_revisions", + ), + CheckConstraint("length(external_id) BETWEEN 1 AND 255", name="ck_catalog_checkpoint_id"), + CheckConstraint("full_synced_at <= checked_at", name="ck_catalog_checkpoint_time"), + CheckConstraint( + "source NOT IN ('comicvine_local', 'gcd_local') OR source_updated_at IS NOT NULL", + name="ck_catalog_checkpoint_generation", + ), + Index("ix_catalog_checkpoint_source", "source"), + Index("ix_catalog_checkpoint_identity", "identity_id"), + ) + + series_id: Mapped[int] = mapped_column( + ForeignKey("series.id", ondelete="CASCADE"), nullable=False + ) + source: Mapped[str] = mapped_column( + String(30), ForeignKey("metadata_source_configs.source", ondelete="CASCADE"), nullable=False + ) + source_revision: Mapped[int] = mapped_column(Integer, nullable=False) + identity_id: Mapped[int] = mapped_column( + ForeignKey("series_external_identities.id", ondelete="CASCADE"), nullable=False + ) + identity_revision: Mapped[int] = mapped_column(Integer, nullable=False) + external_id: Mapped[str] = mapped_column(String(255), nullable=False) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + checked_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False) + full_synced_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False) + source_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime) diff --git a/src/pullbox/services/metadata_catalog_checkpoints.py b/src/pullbox/services/metadata_catalog_checkpoints.py new file mode 100644 index 00000000..f73bd3b1 --- /dev/null +++ b/src/pullbox/services/metadata_catalog_checkpoints.py @@ -0,0 +1,205 @@ +"""Source-bound progress for catalog synchronization, separate from provenance.""" + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Series, SeriesCatalogCheckpoint +from pullbox.models.metadata_identity import SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.services.metadata_writer_identity import metadata_write_scope + + +class CatalogCheckpointConflictError(ValueError): + """The source, identity or previous catalog checkpoint changed.""" + + +@dataclass(frozen=True) +class CatalogCheckpoint: + series_id: int + source: MetadataSource + source_revision: int + identity_id: int + identity_revision: int + external_id: str + revision: int + checked_at: datetime + full_synced_at: datetime + source_updated_at: datetime | None + + +def _snapshot(row: SeriesCatalogCheckpoint) -> CatalogCheckpoint: + return CatalogCheckpoint( + row.series_id, + MetadataSource(row.source), + row.source_revision, + row.identity_id, + row.identity_revision, + row.external_id, + row.revision, + row.checked_at, + row.full_synced_at, + row.source_updated_at, + ) + + +async def read_catalog_checkpoints( + session: AsyncSession, series_id: int +) -> tuple[CatalogCheckpoint, ...]: + """Include invalidated rows so optimistic refresh read sets detect changes.""" + return tuple( + _snapshot(row) + for row in await session.scalars( + select(SeriesCatalogCheckpoint) + .where(SeriesCatalogCheckpoint.series_id == series_id) + .order_by(SeriesCatalogCheckpoint.source) + .execution_options(populate_existing=True) + ) + ) + + +async def load_catalog_checkpoint( + session: AsyncSession, series_id: int, source: MetadataSource +) -> CatalogCheckpoint | None: + """Return progress only while its exact source and identity binding is current. + + Local readers must additionally compare the window's generation before + applying it. A changed generation requires another full synchronization. + """ + row = await session.scalar( + select(SeriesCatalogCheckpoint) + .join(MetadataSourceConfig, MetadataSourceConfig.source == SeriesCatalogCheckpoint.source) + .join( + SeriesExternalIdentity, SeriesExternalIdentity.id == SeriesCatalogCheckpoint.identity_id + ) + .where( + SeriesCatalogCheckpoint.series_id == series_id, + SeriesCatalogCheckpoint.source == source.value, + MetadataSourceConfig.enabled.is_(True), + MetadataSourceConfig.revision == SeriesCatalogCheckpoint.source_revision, + SeriesExternalIdentity.series_id == series_id, + SeriesExternalIdentity.identity_namespace == source.identity_namespace, + SeriesExternalIdentity.external_id == SeriesCatalogCheckpoint.external_id, + SeriesExternalIdentity.revision == SeriesCatalogCheckpoint.identity_revision, + SeriesExternalIdentity.verification_state == IdentityVerificationState.VERIFIED, + ) + .execution_options(populate_existing=True) + ) + return _snapshot(row) if row else None + + +def _utc(value: datetime) -> datetime: + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: + raise CatalogCheckpointConflictError( + "Catalog progress requires an aware request timestamp." + ) + return value.astimezone(UTC) + + +async def save_full_catalog_checkpoint( + session: AsyncSession, + series_id: int, + *, + source: MetadataSource, + source_revision: int, + identity_revision: int, + external_id: str, + started_at: datetime, + source_updated_at: datetime | None = None, + expected_revision: int = 0, +) -> CatalogCheckpoint: + """Save only after a complete catalog is applied, in the caller's transaction. + + Never call with a publication slice or truncated modification window. The + boundary is the live request start, not the time the final page was saved. + """ + if ( + not isinstance(source, MetadataSource) + or type(source_revision) is not int + or source_revision <= 0 + or type(identity_revision) is not int + or identity_revision <= 0 + or type(expected_revision) is not int + or expected_revision < 0 + ): + raise CatalogCheckpointConflictError("Invalid catalog source or revision.") + identity = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.SERIES, external_id + ) + if identity.external_id != external_id: + raise CatalogCheckpointConflictError("Catalog progress requires an exact identity.") + started_at = _utc(started_at) + if started_at > datetime.now(UTC): + raise CatalogCheckpointConflictError("Catalog progress cannot start in the future.") + local = source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL} + if local and source_updated_at is None: + raise CatalogCheckpointConflictError("Local catalog progress requires its generation.") + generation = _utc(source_updated_at) if local and source_updated_at else None + try: + async with metadata_write_scope(session): + config = await session.scalar( + select(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ) + parent = await session.scalar( + select(Series.id).where(Series.id == series_id).with_for_update() + ) + claim = await session.scalar( + select(SeriesExternalIdentity) + .where( + SeriesExternalIdentity.series_id == series_id, + SeriesExternalIdentity.identity_namespace == source.identity_namespace, + ) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ) + if ( + parent is None + or config is None + or not config.enabled + or config.revision != source_revision + or claim is None + or claim.external_id != external_id + or claim.revision != identity_revision + or claim.verification_state is not IdentityVerificationState.VERIFIED + ): + raise CatalogCheckpointConflictError("Catalog source or series identity changed.") + row = await session.scalar( + select(SeriesCatalogCheckpoint) + .where( + SeriesCatalogCheckpoint.series_id == series_id, + SeriesCatalogCheckpoint.source == source.value, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if (row.revision if row else 0) != expected_revision: + raise CatalogCheckpointConflictError( + "Catalog progress changed. Read the catalog again." + ) + if row is not None and started_at < row.checked_at: + raise CatalogCheckpointConflictError( + "An older catalog cannot replace newer progress." + ) + if row is None: + row = SeriesCatalogCheckpoint(series_id=series_id, source=source.value) + session.add(row) + row.source_revision = source_revision + row.identity_id, row.identity_revision = claim.id, claim.revision + row.external_id = external_id + row.revision = expected_revision + 1 + row.checked_at = row.full_synced_at = started_at + row.source_updated_at = generation + await session.flush() + return _snapshot(row) + except IntegrityError as exc: + raise CatalogCheckpointConflictError( + "Catalog progress changed. Read the catalog again." + ) from exc diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index e000f77f..e3e95640 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -3,6 +3,7 @@ import asyncio import hashlib import json +from copy import copy from dataclasses import dataclass from datetime import UTC, datetime from uuid import UUID @@ -43,6 +44,10 @@ load_metadata_baseline, save_metadata_baselines, ) +from pullbox.services.metadata_catalog_checkpoints import ( + CatalogCheckpointConflictError, + save_full_catalog_checkpoint, +) from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation @@ -83,6 +88,7 @@ class SourceSeriesBundle: issues: tuple[ProviderIssueRead, ...] source_revision: int catalog_total: int + catalog_started_at: datetime | None = None @dataclass(frozen=True) @@ -109,6 +115,15 @@ async def fetch_source_series_bundle( raise SeriesAdoptionError("Metadata source settings changed. Preview the series again.") if type(max_issues) is not int or not 1 <= max_issues <= 10000 or not 0 < timeout <= 120: raise ValueError("Invalid adoption resource limits") + if registry.revalidate_reads and registry.read_cache is not None: + # A coalesced response may have started before this fetch. Preserve the + # caller's cache and concurrency budget but read this full catalog live. + registry = copy(registry) + registry.read_cache = None + # Cached pages without revalidation cannot establish a new modification floor. + started_at = ( + datetime.now(UTC) if registry.read_cache is None or registry.revalidate_reads else None + ) try: async with asyncio.timeout(timeout): if profile is None: @@ -169,7 +184,9 @@ async def fetch_source_series_bundle( if current.next_page is None: if len(issues) != total: raise SeriesAdoptionError("The issue catalog is incomplete. Retry the add.") - return SourceSeriesBundle(profile, tuple(issues), source_revision, total) + return SourceSeriesBundle( + profile, tuple(issues), source_revision, total, started_at + ) page = current.next_page except TimeoutError as exc: raise SeriesAdoptionError( @@ -200,6 +217,10 @@ async def adopt_source_series_bundle( "Metadata source settings changed. Preview the series again." ) return await _adopt(session, bundle, numbers, monitored=monitored) + except CatalogCheckpointConflictError as exc: + raise SeriesAdoptionError( + "Catalog progress could not be saved safely. Preview the series again." + ) from exc except (ValidationError, IntegrityError) as exc: raise SeriesAdoptionError( "Metadata identity conflicts with an existing match. Review the match before retrying." @@ -529,6 +550,24 @@ async def _adopt( await MetadataService.infer_series_status(session, series) if snapshot is not None: snapshot = await persist_adoption_series_baseline(session, series, snapshot) + if bundle.catalog_started_at is not None: + claim = await session.scalar( + select(SeriesExternalIdentity).where( + SeriesExternalIdentity.series_id == series.id, + SeriesExternalIdentity.identity_namespace == profile.identity_namespace, + ) + ) + assert claim is not None + await save_full_catalog_checkpoint( + session, + series.id, + source=profile.source, + source_revision=bundle.source_revision, + identity_revision=claim.revision, + external_id=profile.external_id, + started_at=bundle.catalog_started_at, + source_updated_at=profile.source_updated_at, + ) logger.info( "metadata_series_added", series_id=series.id, diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index 8f45e591..dbe5b36d 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -29,6 +29,7 @@ from pullbox.services.cover_resolver import resolve_covers_dir from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation @@ -230,6 +231,28 @@ async def refresh_series_from_sources( series = await _apply( session, current, bundle, snapshot, now, replace_managed=replace_managed ) + if bundle.catalog_started_at is not None: + checkpoint = next( + (item for item in current.checkpoints if item.source is bundle.series.source), + None, + ) + identity_revision = next( + revision + for ref, _, revision in current.series.claims + if ref.namespace is bundle.series.identity_namespace + and ref.external_id == bundle.series.external_id + ) + await save_full_catalog_checkpoint( + session, + series.id, + source=bundle.series.source, + source_revision=bundle.source_revision, + identity_revision=identity_revision, + external_id=bundle.series.external_id, + started_at=bundle.catalog_started_at, + source_updated_at=bundle.series.source_updated_at, + expected_revision=checkpoint.revision if checkpoint else 0, + ) await session.flush() logger.info( "metadata_series_source_refreshed", diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index 225b606a..e3221eae 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -21,6 +21,10 @@ from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues from pullbox.schemas.metadata_sources import SourcePolicyRead from pullbox.services.metadata_baselines import MetadataBaselineConflictError +from pullbox.services.metadata_catalog_checkpoints import ( + CatalogCheckpoint, + read_catalog_checkpoints, +) from pullbox.services.metadata_sources import read_source_policies SERIES_FIELDS = frozenset( @@ -75,6 +79,7 @@ class SeriesRefreshState: series: RefreshEntityState issues: tuple[RefreshEntityState, ...] policies: tuple[SourcePolicyRead, ...] + checkpoints: tuple[CatalogCheckpoint, ...] = () def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot | None: @@ -226,4 +231,6 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se ) for item in await read_source_policies(session) ) - return SeriesRefreshState(parent, tuple(issues), policies) + return SeriesRefreshState( + parent, tuple(issues), policies, await read_catalog_checkpoints(session, series_id) + ) diff --git a/tests/integration/metadata_identity/test_catalog_checkpoints.py b/tests/integration/metadata_identity/test_catalog_checkpoints.py new file mode 100644 index 00000000..484403ff --- /dev/null +++ b/tests/integration/metadata_identity/test_catalog_checkpoints.py @@ -0,0 +1,527 @@ +"""A completed source catalog must never advance a different source's cursor.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import delete, select, update + +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Base, Issue, Series, SeriesCatalogCheckpoint +from pullbox.models.metadata_identity import SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.services.metadata_catalog_checkpoints import ( + CatalogCheckpointConflictError, + load_catalog_checkpoint, + read_catalog_checkpoints, + save_full_catalog_checkpoint, +) +from pullbox.services.metadata_series_adoption import ( + SeriesAdoptionError, + adopt_source_series_bundle, + fetch_source_series_bundle, +) +from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_from_sources +from tests.integration.metadata_identity.test_series_adoption import ( # noqa: F401 + bundle, + configured_sources, +) +from tests.integration.metadata_identity.test_series_refresh import RefreshAdapter, refresh_registry + +START = datetime(2026, 1, 1, tzinfo=UTC) + + +async def seed(factory): + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, bundle(crosswalk=True)) + return result.series.id + + +async def save(session, series_id, **kwargs): + return await save_full_catalog_checkpoint( + session, + series_id, + **{ + "source": Source.METRON_API, + "source_revision": 1, + "identity_revision": 1, + "external_id": "42", + "started_at": START, + **kwargs, + }, + ) + + +async def test_checkpoints_survive_restart_and_keep_source_cursors_independent(identity_probe_db): + engine, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + first = await save(session, local_id) + assert first is not None, "Full catalogs need a durable source-specific checkpoint" + await save( + session, + local_id, + source=Source.COMICVINE_API, + external_id="500", + started_at=START + timedelta(hours=1), + ) + await engine.dispose() + async with factory() as session: + metron = await load_catalog_checkpoint(session, local_id, Source.METRON_API) + cv = await load_catalog_checkpoint(session, local_id, Source.COMICVINE_API) + assert metron == first and metron.checked_at == metron.full_synced_at == START + assert cv.checked_at == START + timedelta(hours=1) + assert await load_catalog_checkpoint(session, local_id, Source.COMICVINE_LOCAL) is None + assert len(await read_catalog_checkpoints(session, local_id)) == 2 + + +async def test_checkpoint_write_leaves_commit_to_the_catalog_caller(identity_probe_db): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory() as session: + assert await save(session, local_id) is not None + (await session.get(Series, local_id)).title = "Uncommitted" + await session.rollback() + async with factory() as session: + assert await read_catalog_checkpoints(session, local_id) == () + assert (await session.get(Series, local_id)).title == "Source-only series" + + +@pytest.mark.parametrize( + "change", ["policy", "disabled", "identity", "identity_revision", "stale", "conflicted"] +) +async def test_changed_source_or_identity_requires_a_full_read_again(identity_probe_db, change): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + assert await save(session, local_id) is not None + async with factory.begin() as session: + if change in {"policy", "disabled"}: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == "metron_api") + .values(**({"revision": 2} if change == "policy" else {"enabled": False})) + ) + else: + await session.execute( + update(SeriesExternalIdentity) + .where(SeriesExternalIdentity.identity_namespace == "metron") + .values( + **( + {"external_id": "43", "revision": 2} + if change == "identity" + else {"revision": 2} + if change == "identity_revision" + else {"verification_state": change, "revision": 2} + ) + ) + ) + async with factory() as session: + assert await load_catalog_checkpoint(session, local_id, Source.METRON_API) is None + assert len(await read_catalog_checkpoints(session, local_id)) == 1 + with pytest.raises(CatalogCheckpointConflictError): + await save(session, local_id, expected_revision=1) + + +@pytest.mark.parametrize("revision", [0, 1]) +async def test_concurrent_full_catalog_completions_have_one_winner(identity_probe_db, revision): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + if revision: + async with factory.begin() as session: + assert await save(session, local_id) is not None + + async def write(hour): + async with factory.begin() as session: + try: + return await save( + session, + local_id, + expected_revision=revision, + started_at=START + timedelta(hours=hour), + ) + except CatalogCheckpointConflictError: + return None + + results = await asyncio.wait_for(asyncio.gather(write(1), write(2)), 15) + assert results.count(None) == 1, "Only one writer may advance a captured checkpoint" + async with factory() as session: + assert ( + await load_catalog_checkpoint(session, local_id, Source.METRON_API) + ).revision == revision + 1 + + +@pytest.mark.parametrize("parent", [Series, SeriesExternalIdentity, MetadataSourceConfig]) +async def test_deleting_checkpoint_owner_cascades_progress_only(identity_probe_db, parent): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + assert await save(session, local_id) is not None + async with factory.begin() as session: + await session.execute(delete(parent)) + async with factory() as session: + assert await read_catalog_checkpoints(session, local_id) == () + if parent is not Series: + assert (await session.get(Series, local_id)).title == "Source-only series" + + +async def test_refresh_records_request_start_not_completion_and_rolls_back_atomically( + identity_probe_db, +): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory() as session: + before = datetime.now(UTC) + adapter = RefreshAdapter(bundle()) + request_starts = [] + original_issues = adapter.issues + + async def capture_start(*args, **kwargs): + request_starts.append(datetime.now(UTC)) + return await original_issues(*args, **kwargs) + + adapter.issues = capture_start + await refresh_series_from_sources(session, local_id, registry=refresh_registry(adapter)) + checkpoint = await load_catalog_checkpoint(session, local_id, Source.METRON_API) + assert checkpoint is not None, "Ordinary full refresh must seed the incremental cursor" + assert before <= checkpoint.checked_at <= request_starts[0] + assert checkpoint.full_synced_at == checkpoint.checked_at + await session.rollback() + async with factory() as session: + assert await read_catalog_checkpoints(session, local_id) == () + + +async def test_unknown_bundle_age_and_existing_add_do_not_invent_a_checkpoint(identity_probe_db): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + assert await read_catalog_checkpoints(session, local_id) == () + # A legacy server bundle carries no proven live-read boundary. + result = await adopt_source_series_bundle(session, bundle(crosswalk=True)) + assert not result.created + assert await read_catalog_checkpoints(session, local_id) == () + + +@pytest.mark.parametrize("count", [0, 2]) +async def test_live_fetch_seeds_only_the_new_native_series_checkpoint(identity_probe_db, count): + _, factory, _ = identity_probe_db + data = bundle(crosswalk=True, numbers=tuple(str(i + 1) for i in range(count))) + fetched = await fetch_source_series_bundle( + refresh_registry(RefreshAdapter(data)), + Source.METRON_API, + "42", + source_revision=1, + ) + assert fetched.catalog_started_at is not None + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, fetched) + local_id = result.series.id + async with factory.begin() as session: + original = await load_catalog_checkpoint(session, local_id, Source.METRON_API) + assert original is not None and original.checked_at == fetched.catalog_started_at + assert await load_catalog_checkpoint(session, local_id, Source.COMICVINE_API) is None + result = await adopt_source_series_bundle( + session, replace(fetched, catalog_started_at=datetime.now(UTC)) + ) + assert not result.created + assert await load_catalog_checkpoint(session, local_id, Source.METRON_API) == original + + +@pytest.mark.parametrize("revalidate", [False, True]) +async def test_cached_catalog_does_not_invent_a_new_live_read_boundary( + identity_probe_db, revalidate +): + from pydantic import SecretStr + + from pullbox.services.metadata_read_cache import MetadataReadCache + from pullbox.services.metadata_sources import SourceRuntime + + _, factory, _ = identity_probe_db + adapter = RefreshAdapter(bundle()) + registry = refresh_registry(adapter) + registry.runtime[Source.METRON_API] = SourceRuntime( + registry.runtime[Source.METRON_API].policy, SecretStr("checkpoint-test-token") + ) + registry.read_cache = MetadataReadCache(factory) + first = await fetch_source_series_bundle(registry, Source.METRON_API, "42", source_revision=1) + assert first.catalog_started_at is None + calls = len(adapter.calls) + registry.revalidate_reads = revalidate + second = await fetch_source_series_bundle(registry, Source.METRON_API, "42", source_revision=1) + assert (second.catalog_started_at is not None) is revalidate + assert len(adapter.calls) == calls + (2 if revalidate else 0) + + +async def test_revalidated_catalog_checkpoint_cannot_join_an_older_cached_flight(identity_probe_db): + from pydantic import SecretStr + + from pullbox.services.metadata_read_cache import MetadataReadCache + from pullbox.services.metadata_sources import SourceRuntime + + _, factory, _ = identity_probe_db + registry = refresh_registry(RefreshAdapter(bundle())) + registry.runtime[Source.METRON_API] = SourceRuntime( + registry.runtime[Source.METRON_API].policy, SecretStr("checkpoint-test-token") + ) + cache = MetadataReadCache(factory) + + async def unexpected_cache(*args, **kwargs): + pytest.fail("Checkpoint reads cannot reuse an earlier in-flight cache response") + + cache.get = unexpected_cache + registry.read_cache = cache + registry.revalidate_reads = True + fetched = await fetch_source_series_bundle(registry, Source.METRON_API, "42", source_revision=1) + assert fetched.catalog_started_at is not None + assert registry.read_cache is cache, "The caller's ordinary read cache must remain configured" + + +@pytest.mark.parametrize( + "invalid", ["naive", "future", "old", "revision", "identity_revision", "local_generation"] +) +async def test_invalid_checkpoint_cannot_overwrite_progress(identity_probe_db, invalid): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + original = await save(session, local_id) + args = {"expected_revision": 1, "started_at": START + timedelta(hours=1)} + args.update( + { + "naive": {"started_at": START.replace(tzinfo=None)}, + "future": {"started_at": datetime.now(UTC) + timedelta(days=1)}, + "old": {"started_at": START - timedelta(seconds=1)}, + "revision": {"expected_revision": 0}, + "identity_revision": {"identity_revision": 2}, + "local_generation": { + "source": Source.COMICVINE_LOCAL, + "external_id": "500", + "expected_revision": 0, + }, + }[invalid] + ) + async with factory.begin() as session: + with pytest.raises(CatalogCheckpointConflictError): + await save(session, local_id, **args) + async with factory() as session: + assert await read_catalog_checkpoints(session, local_id) == (original,) + + +async def test_local_generation_and_policy_rebinding_require_a_new_full_sync(identity_probe_db): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + await save( + session, + local_id, + source=Source.COMICVINE_LOCAL, + external_id="500", + source_updated_at=START, + ) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == "comicvine_local") + .values(revision=2) + ) + async with factory.begin() as session: + assert await load_catalog_checkpoint(session, local_id, Source.COMICVINE_LOCAL) is None + newer = await save( + session, + local_id, + source=Source.COMICVINE_LOCAL, + source_revision=2, + external_id="500", + source_updated_at=START + timedelta(days=1), + started_at=START + timedelta(days=2), + expected_revision=1, + ) + assert newer.revision == 2 and newer.source_updated_at == START + timedelta(days=1) + assert newer.full_synced_at == START + timedelta(days=2) + + +async def test_checkpoint_failure_rolls_back_new_series_and_issues(identity_probe_db): + _, factory, _ = identity_probe_db + data = replace(bundle(), catalog_started_at=datetime.now(UTC) + timedelta(days=1)) + async with factory.begin() as session: + with pytest.raises(SeriesAdoptionError): + await adopt_source_series_bundle(session, data) + async with factory() as session: + assert list(await session.scalars(select(Series))) == [] + assert list(await session.scalars(select(Issue))) == [] + assert list(await session.scalars(select(SeriesCatalogCheckpoint))) == [] + + +async def test_inflight_checkpoint_change_rejects_refresh_without_overwriting_newer_progress( + identity_probe_db, +): + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + await save(session, local_id) + data = bundle(crosswalk=True) + data.series.description = "Late description" + adapter = RefreshAdapter(data, wait=asyncio.Event()) + async with factory() as session: + task = asyncio.create_task( + refresh_series_from_sources(session, local_id, registry=refresh_registry(adapter)) + ) + try: + await asyncio.wait_for(adapter.started.wait(), 5) + async with factory.begin() as editor: + newer = await save( + editor, local_id, started_at=START + timedelta(days=1), expected_revision=1 + ) + adapter.wait.set() + with pytest.raises(SeriesRefreshError, match="changed"): + await task + await session.commit() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + assert await load_catalog_checkpoint(session, local_id, Source.METRON_API) == newer + assert (await session.get(Series, local_id)).description != "Late description" + + +async def test_checkpoint_migration_roundtrip_preserves_library_and_does_not_backfill( + identity_probe_db, +): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import Column, Integer, MetaData, String, Table + + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + await save(session, local_id) + async with engine.begin() as connection: + + def migrate(sync): + revision = _revision("u2o3p4q5r678_add_catalog_checkpoints", sync) + revision.downgrade() + revision.upgrade() + expected = MetaData() + for parent in ("series", "series_external_identities"): + Table(parent, expected, Column("id", Integer, primary_key=True)) + Table("metadata_source_configs", expected, Column("source", String(30), unique=True)) + name = "series_catalog_checkpoints" + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, item, type_, reflected, compare_to: ( + item == name if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + assert compare_metadata(context, expected) == [] + + await connection.run_sync(migrate) + async with factory.begin() as session: + assert (await session.get(Series, local_id)).title == "Source-only series" + assert await read_catalog_checkpoints(session, local_id) == () + assert await save(session, local_id) is not None + + +async def test_non_utc_request_boundary_is_normalized_without_changing_the_instant( + identity_probe_db, +): + from datetime import timezone + + _, factory, _ = identity_probe_db + local_id = await seed(factory) + offset = START.astimezone(timezone(timedelta(hours=-7))) + async with factory.begin() as session: + saved = await save(session, local_id, started_at=offset) + assert saved.checked_at == START and saved.checked_at.tzinfo is UTC + async with factory() as session: + saved = await load_catalog_checkpoint(session, local_id, Source.METRON_API) + assert saved.checked_at == START and saved.checked_at.tzinfo is UTC + + +@pytest.mark.parametrize( + "invalid", + [ + "orphan_series", + "orphan_source", + "orphan_identity", + "revision", + "source_revision", + "identity_revision", + "empty_id", + "clock_order", + "local_generation", + "duplicate", + ], +) +async def test_database_rejects_invalid_checkpoint_rows(identity_probe_db, invalid): + from sqlalchemy import insert + from sqlalchemy.exc import IntegrityError + + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + original = await save(session, local_id) + values = { + "series_id": local_id, + "source": "metron_api", + "source_revision": 1, + "identity_id": original.identity_id, + "identity_revision": 1, + "external_id": "42", + "revision": 1, + "checked_at": START, + "full_synced_at": START, + } + values.update( + { + "orphan_series": {"series_id": local_id + 1000}, + "orphan_source": {"source": "not_configured"}, + "orphan_identity": {"identity_id": original.identity_id + 1000}, + "revision": {"revision": 0}, + "source_revision": {"source_revision": 0}, + "identity_revision": {"identity_revision": 0}, + "empty_id": {"external_id": ""}, + "clock_order": {"full_synced_at": START + timedelta(seconds=1)}, + "local_generation": {"source": "comicvine_local"}, + "duplicate": {}, + }[invalid] + ) + if invalid != "duplicate": + async with factory.begin() as session: + await session.execute(delete(SeriesCatalogCheckpoint)) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(insert(SeriesCatalogCheckpoint).values(**values)) + + +async def test_checkpoint_failure_after_refresh_apply_rolls_back_metadata_too( + identity_probe_db, monkeypatch +): + from pullbox.models.metadata_baseline import SeriesMetadataBaseline + from pullbox.services import metadata_series_refresh as refresh_module + + _, factory, _ = identity_probe_db + local_id = await seed(factory) + async with factory.begin() as session: + original = await save(session, local_id) + data = bundle(crosswalk=True) + data.series.description = "Must roll back" + + async def refuse(*args, **kwargs): + raise CatalogCheckpointConflictError("Concurrent checkpoint") + + monkeypatch.setattr(refresh_module, "save_full_catalog_checkpoint", refuse) + async with factory() as session: + with pytest.raises(SeriesRefreshError): + await refresh_series_from_sources( + session, local_id, registry=refresh_registry(RefreshAdapter(data)) + ) + await session.commit() + async with factory() as session: + assert (await session.get(Series, local_id)).description != "Must roll back" + assert await load_catalog_checkpoint(session, local_id, Source.METRON_API) == original + assert (await session.scalar(select(SeriesMetadataBaseline))).revision == 1 diff --git a/tests/integration/metadata_identity/test_production_migration.py b/tests/integration/metadata_identity/test_production_migration.py index 1de90567..1270f7e2 100644 --- a/tests/integration/metadata_identity/test_production_migration.py +++ b/tests/integration/metadata_identity/test_production_migration.py @@ -60,6 +60,7 @@ def _data(connection): async def _predecessor(engine): async with engine.begin() as connection: + await connection.run_sync(Base.metadata.tables["series_catalog_checkpoints"].drop) for name in reversed(_NAMES): await connection.run_sync(Base.metadata.tables[name].drop) diff --git a/tests/integration/metadata_identity/test_source_configuration.py b/tests/integration/metadata_identity/test_source_configuration.py index 7f1f6a4e..fb7f37e5 100644 --- a/tests/integration/metadata_identity/test_source_configuration.py +++ b/tests/integration/metadata_identity/test_source_configuration.py @@ -215,6 +215,8 @@ async def test_actual_source_migration_roundtrip_preserves_canonical_credentials async with engine.begin() as connection: def migrate(sync): + checkpoints = _revision("u2o3p4q5r678_add_catalog_checkpoints", sync) + checkpoints.downgrade() migration = _revision("s0m1n2o3p456_add_metadata_source_configuration", sync) migration.downgrade() migration.upgrade() @@ -229,6 +231,7 @@ def migrate(sync): }, ) assert compare_metadata(context, expected) == [] + checkpoints.upgrade() await connection.run_sync(migrate) async with factory() as session: diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 1322a1d9..a917cf30 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["t1n2o3p4q567"] + assert script.get_heads() == ["u2o3p4q5r678"] + assert script.get_revision("u2o3p4q5r678").down_revision == "t1n2o3p4q567" assert script.get_revision("t1n2o3p4q567").down_revision == "s0m1n2o3p456" assert script.get_revision("s0m1n2o3p456").down_revision == "r9l0m1n2o345" assert script.get_revision("r9l0m1n2o345").down_revision == "q8k9l0m1n234" From e7dd4ec3086a4ccd3d9c9f6eab483d137381f61a Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 15:33:09 -0700 Subject: [PATCH 49/97] refactor(metadata): share canonical issue catalog writes --- docs/development/ARCHITECTURE_OVERVIEW.md | 10 + .../services/metadata_issue_catalog.py | 162 ++++++++++ .../services/metadata_scheduled_refresh.py | 27 +- .../services/metadata_series_adoption.py | 30 +- .../services/metadata_series_refresh.py | 163 +++------- .../test_issue_catalog_writer.py | 306 ++++++++++++++++++ 6 files changed, 567 insertions(+), 131 deletions(-) create mode 100644 src/pullbox/services/metadata_issue_catalog.py create mode 100644 tests/integration/metadata_identity/test_issue_catalog_writer.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 406ebe78..6c1d85e8 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -346,6 +346,16 @@ an in-flight refresh cannot overwrite newer sync progress. Incremental consumers must still handle overlap, generation changes and truncated modification windows; this checkpoint storage does not itself implement daily source-aware sync. +`services/metadata_issue_catalog.py` applies either complete membership or an +issue-only batch to the caller's locked, revalidated read set. Full refresh uses +this writer; partial batches do not imply removal, completeness or a new full-sync +timestamp. Both modes preserve issue ownership, local edits, private reading state +and registered artifacts while sharing native identity, crosswalk observation and +canonical-baseline writes. The writer returns exact created issue IDs. Scheduled +refresh uses that receipt, not an ID range that could include another operation's +new issues, when deciding whether to search after commit. Daily source-window +orchestration and atomic incremental checkpoint advancement remain pending. + `services/metadata_series_adoption.py` separates a complete server-side catalog fetch from transactional adoption. Traversal rejects partial pages, repeated identities, changed counts and mixed local catalog generations. The writer locks diff --git a/src/pullbox/services/metadata_issue_catalog.py b/src/pullbox/services/metadata_issue_catalog.py new file mode 100644 index 00000000..b59dafa2 --- /dev/null +++ b/src/pullbox/services/metadata_issue_catalog.py @@ -0,0 +1,162 @@ +"""Shared issue-only writes for complete catalogs and bounded source windows.""" + +from dataclasses import replace +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind +from pullbox.models import Issue, Series +from pullbox.models.issue import IssueStatus +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_identity_attachment import attach_verified_identities +from pullbox.services.metadata_identity_review import record_identity_observation +from pullbox.services.metadata_series_adoption import ( + SourceIssueBatch as SourceIssueBatch, +) +from pullbox.services.metadata_series_adoption import ( + _identities, + _metadata_label, + _request, + _require_unowned_issues, + _validate_issue_batch, +) +from pullbox.services.metadata_series_refresh_state import ISSUE_FIELDS, SeriesRefreshState +from pullbox.services.metadata_service import classify_issue_metadata + + +class IssueCatalogConflictError(ValueError): + """A source issue cannot safely update its canonical library target.""" + + +async def apply_issue_batch( + session: AsyncSession, + state: SeriesRefreshState, + batch: SourceIssueBatch, + now: datetime, + *, + complete: bool = False, + replace_managed: bool = False, +) -> tuple[int, ...]: + """Apply to a locked, revalidated read set; return exactly the created IDs. + + The caller owns transaction rollback, series metadata, completeness and + checkpoint updates. This writer never starts provider or filesystem work. + """ + source = batch.source + parent = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.SERIES, batch.series_external_id + ) + policy = next((item for item in state.policies if item.source is source), None) + if ( + parent.external_id != batch.series_external_id + or parent not in state.series.identities + or policy is None + or not policy.enabled + or policy.revision != batch.source_revision + ): + raise IssueCatalogConflictError("Catalog source or verified series identity changed.") + try: + numbers = _validate_issue_batch(batch) + except ValueError as exc: + raise IssueCatalogConflictError(str(exc)) from exc + by_identity = {identity: item for item in state.issues for identity in item.identities} + offered = { + ExternalIdentityRef(source.identity_namespace, MetadataEntityKind.ISSUE, item.external_id) + for item in batch.issues + } + missing = { + identity for identity in by_identity if identity.namespace is source.identity_namespace + } - offered + if complete and missing: + raise IssueCatalogConflictError( + "The provider removed existing issue identities. " + "Review the catalog; existing issues and files were kept." + ) + by_number = {item.values.issue_number_text: item for item in state.issues} + prepared = [] + for metadata, (number, text) in zip(batch.issues, numbers, strict=True): + identity = ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id + ) + existing = by_identity.get(identity) + if existing is not None and existing.values.issue_number_text != text: + raise IssueCatalogConflictError( + "A provider issue was renumbered. Review the issue match; existing files were kept." + ) + if existing is None and text in by_number: + raise IssueCatalogConflictError( + "An issue designation already belongs to a different identity. " + "Review its match; no issues were reassigned." + ) + snapshot = assemble_metadata( + MetadataEntityKind.ISSUE, + existing.identities if existing else (identity,), + [metadata.model_copy(update={"issue_number_text": text})], + state.policies, + now=now, + current=existing.values if existing else None, + previous=existing.baseline if existing else None, + parent_identities=state.series.identities, + replace_managed=replace_managed, + fields=ISSUE_FIELDS, + ) + prepared.append((existing, metadata, number, text, snapshot)) + new_metadata = tuple(metadata for old, metadata, *_rest in prepared if old is None) + if new_metadata: + await _require_unowned_issues(session, replace(batch, issues=new_metadata)) + series = await session.get(Series, state.series.local_id) + if series is None: + raise IssueCatalogConflictError("The series no longer exists.") + existing_issues = { + item.id: item + for item in await session.scalars(select(Issue).where(Issue.series_id == series.id)) + } + created = [] + for offset in range(0, len(prepared), 200): + pending = [] + for existing, metadata, number, text, snapshot in prepared[offset : offset + 200]: + issue = existing_issues.get(existing.local_id) if existing else None + if issue is None: + issue = Issue( + series_id=series.id, + issue_number=number, + issue_number_text=text, + status=IssueStatus.WANTED if series.monitored else IssueStatus.SKIPPED, + issue_type=classify_issue_metadata(series.series_type, metadata.title)[1], + metadata_source=_metadata_label(source), + ) + session.add(issue) + value = snapshot.values + issue.title, issue.description = value.title, value.description + issue.release_date, issue.store_date = value.cover_date, value.store_date + issue.page_count, issue.cover_url = value.page_count, value.image_url + pending.append((issue, existing, metadata, snapshot)) + await session.flush() + await attach_verified_identities( + session, + [ + _request( + batch, metadata, _identities(metadata, MetadataEntityKind.ISSUE)[0], issue.id + ) + for issue, old, metadata, _ in pending + if old is None + ], + ) + for issue, old, metadata, _ in pending: + if old is None: + created.append(issue.id) + for crosswalk in _identities(metadata, MetadataEntityKind.ISSUE)[1:]: + await record_identity_observation( + session, _request(batch, metadata, crosswalk, issue.id, observation=True) + ) + await save_metadata_baselines( + session, + [ + MetadataBaselineWrite(issue.id, item, old.baseline_revision if old else 0) + for issue, old, _, item in pending + ], + ) + return tuple(created) diff --git a/src/pullbox/services/metadata_scheduled_refresh.py b/src/pullbox/services/metadata_scheduled_refresh.py index 8d99a64c..5fb9271b 100644 --- a/src/pullbox/services/metadata_scheduled_refresh.py +++ b/src/pullbox/services/metadata_scheduled_refresh.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from pathlib import Path -from sqlalchemy import exists, func, select +from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement @@ -15,7 +15,7 @@ from pullbox.schemas.metadata_sources import SourceCapability from pullbox.services.cover_resolver import resolve_covers_dir from pullbox.services.metadata_discovery import MetadataSourceRegistry -from pullbox.services.metadata_series_refresh import refresh_series_from_sources +from pullbox.services.metadata_series_refresh import refresh_series_catalog_from_sources from pullbox.services.metadata_sources import load_source_runtime @@ -55,16 +55,23 @@ class ScheduledSeriesRefresh: async def refresh_scheduled_series(session: AsyncSession, series_id: int) -> ScheduledSeriesRefresh: """Fill gaps and synchronize a complete catalog; the task commits progress too.""" covers = await resolve_covers_dir(session) - before_id = await session.scalar(select(func.max(Issue.id)).where(Issue.series_id == series_id)) - series = await refresh_series_from_sources(session, series_id, replace_managed=False) - new_issues = list( - await session.scalars( - select(Issue).where(Issue.series_id == series_id, Issue.id > (before_id or 0)) + result = await refresh_series_catalog_from_sources(session, series_id, replace_managed=False) + wanted = ( + await session.scalar( + select(Issue.id) + .where( + Issue.series_id == series_id, + Issue.id.in_(result.created_issue_ids), + Issue.status == IssueStatus.WANTED, + ) + .limit(1) ) + if result.created_issue_ids + else None ) return ScheduledSeriesRefresh( - len(new_issues), - series.monitored and any(issue.status is IssueStatus.WANTED for issue in new_issues), - series.cover_url, + len(result.created_issue_ids), + result.series.monitored and wanted is not None, + result.series.cover_url, covers, ) diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index e3e95640..a94ac515 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -82,6 +82,14 @@ def __init__( self.retry_after_seconds = retry_after_seconds +@dataclass(frozen=True) +class SourceIssueBatch: + source: MetadataSource + series_external_id: str + issues: tuple[ProviderIssueRead, ...] + source_revision: int + + @dataclass(frozen=True) class SourceSeriesBundle: series: ProviderSeriesRead @@ -263,11 +271,23 @@ def _validate_bundle(bundle: SourceSeriesBundle) -> list[tuple[float, str]]: or (profile.issue_count is not None and profile.issue_count != len(bundle.issues)) ): raise SeriesAdoptionError("The series profile or issue catalog is incomplete.") + return _validate_issue_batch( + SourceIssueBatch(profile.source, profile.external_id, bundle.issues, bundle.source_revision) + ) + + +def _validate_issue_batch(batch: SourceIssueBatch) -> list[tuple[float, str]]: + if ( + type(batch.source_revision) is not int + or batch.source_revision <= 0 + or len(batch.issues) > 10000 + ): + raise SeriesAdoptionError("Invalid catalog revision or issue batch size.") numbers = [] seen_numbers: set[str] = set() seen_ids: set[ExternalIdentityRef] = set() - for issue in bundle.issues: - if issue.source is not profile.source or issue.series_external_id != profile.external_id: + for issue in batch.issues: + if issue.source is not batch.source or issue.series_external_id != batch.series_external_id: raise SeriesAdoptionError("An issue belongs to a different source or series.") for identity in _identities(issue, MetadataEntityKind.ISSUE): if identity in seen_ids: @@ -289,7 +309,7 @@ def _validate_bundle(bundle: SourceSeriesBundle) -> list[tuple[float, str]]: def _request( - bundle: SourceSeriesBundle, + bundle: SourceSeriesBundle | SourceIssueBatch, metadata: ProviderSeriesRead | ProviderIssueRead, identity: ExternalIdentityRef, local_id: int, @@ -368,7 +388,9 @@ async def _series_owner( return result.scalar_one_or_none() -async def _require_unowned_issues(session: AsyncSession, bundle: SourceSeriesBundle) -> None: +async def _require_unowned_issues( + session: AsyncSession, bundle: SourceSeriesBundle | SourceIssueBatch +) -> None: identities = [ identity for issue in bundle.issues diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index dbe5b36d..3aeb6b42 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -13,9 +14,8 @@ from pullbox.config import get_settings from pullbox.core.exceptions import ValidationError -from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource from pullbox.models import Issue, Series -from pullbox.models.issue import IssueStatus from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, field_domain @@ -31,27 +31,25 @@ from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint from pullbox.services.metadata_discovery import MetadataSourceRegistry -from pullbox.services.metadata_identity_attachment import attach_verified_identities -from pullbox.services.metadata_identity_review import record_identity_observation +from pullbox.services.metadata_issue_catalog import ( + IssueCatalogConflictError, + SourceIssueBatch, + apply_issue_batch, +) from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot from pullbox.services.metadata_series_adoption import ( SeriesAdoptionError, SourceSeriesBundle, - _identities, - _metadata_label, - _request, - _require_unowned_issues, _validate_bundle, fetch_source_series_bundle, ) from pullbox.services.metadata_series_refresh_state import ( - ISSUE_FIELDS, SERIES_FIELDS, SeriesRefreshState, read_series_refresh_state, ) -from pullbox.services.metadata_service import MetadataService, classify_issue_metadata +from pullbox.services.metadata_service import MetadataService from pullbox.services.metadata_sources import load_source_runtime from pullbox.services.metadata_writer_identity import metadata_write_scope from pullbox.services.provider_artwork import ProviderArtworkClient, pending_provider_cover @@ -74,6 +72,12 @@ def __init__( self.retry_after_seconds = retry_after_seconds +@dataclass(frozen=True) +class SeriesCatalogRefresh: + series: Series + created_issue_ids: tuple[int, ...] + + @asynccontextmanager async def source_series_refresh_transaction( session: AsyncSession, series_id: int @@ -136,6 +140,20 @@ async def refresh_series_from_sources( registry: MetadataSourceRegistry | None = None, replace_managed: bool = True, ) -> Series: + """Refresh metadata through the canonical writer, preserving the public result.""" + result = await refresh_series_catalog_from_sources( + session, series_id, registry=registry, replace_managed=replace_managed + ) + return result.series + + +async def refresh_series_catalog_from_sources( + session: AsyncSession, + series_id: int, + *, + registry: MetadataSourceRegistry | None = None, + replace_managed: bool = True, +) -> SeriesCatalogRefresh: """Release only a clean read transaction; leave the atomic write to the caller. No library/archive files are modified. The caller owns response construction, @@ -228,7 +246,7 @@ async def refresh_series_from_sources( raise SeriesRefreshError( "Library metadata, identities or source settings changed. Retry the refresh." ) - series = await _apply( + result = await _apply( session, current, bundle, snapshot, now, replace_managed=replace_managed ) if bundle.catalog_started_at is not None: @@ -244,7 +262,7 @@ async def refresh_series_from_sources( ) await save_full_catalog_checkpoint( session, - series.id, + result.series.id, source=bundle.series.source, source_revision=bundle.source_revision, identity_revision=identity_revision, @@ -260,9 +278,11 @@ async def refresh_series_from_sources( source=bundle.series.source.value, catalog_count=bundle.catalog_total, ) - return series + return result except SeriesRefreshError: raise + except IssueCatalogConflictError as exc: + raise SeriesRefreshError(str(exc)) from exc except (ValueError, IntegrityError, ValidationError) as exc: raise SeriesRefreshError( "Metadata could not be refreshed safely. Review the series match " @@ -356,66 +376,10 @@ async def _apply( now: datetime, *, replace_managed: bool, -) -> Series: +) -> SeriesCatalogRefresh: series = await session.get(Series, state.series.local_id) assert series is not None - source = bundle.series.source - by_identity = {identity: item for item in state.issues for identity in item.identities} - numbers = _validate_bundle(bundle) - offered = { - ExternalIdentityRef(source.identity_namespace, MetadataEntityKind.ISSUE, item.external_id) - for item in bundle.issues - } - missing = { - identity for identity in by_identity if identity.namespace is source.identity_namespace - } - offered - if missing: - raise SeriesRefreshError( - "The provider removed existing issue identities. " - "Review the catalog; existing issues and files were kept." - ) - by_number = {item.values.issue_number_text: item for item in state.issues} - prepared = [] - for metadata, (number, text) in zip(bundle.issues, numbers, strict=True): - identity = ExternalIdentityRef( - source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id - ) - existing = by_identity.get(identity) - if existing is not None and existing.values.issue_number_text != text: - raise SeriesRefreshError( - "A provider issue was renumbered. Review the issue match; existing files were kept." - ) - if existing is None and text in by_number: - raise SeriesRefreshError( - "An issue designation already belongs to a different identity. " - "Review its match; no issues were reassigned." - ) - issue_snapshot = assemble_metadata( - MetadataEntityKind.ISSUE, - existing.identities if existing else (identity,), - [metadata.model_copy(update={"issue_number_text": text})], - state.policies, - now=now, - current=existing.values if existing else None, - previous=existing.baseline if existing else None, - parent_identities=state.series.identities, - replace_managed=replace_managed, - fields=ISSUE_FIELDS, - ) - prepared.append((existing, metadata, number, text, issue_snapshot)) values = snapshot.values - new_metadata = tuple(metadata for old, metadata, *_rest in prepared if old is None) - if new_metadata: - await _require_unowned_issues( - session, - SourceSeriesBundle( - bundle.series, new_metadata, bundle.source_revision, len(new_metadata) - ), - ) - existing_issues = { - item.id: item - for item in await session.scalars(select(Issue).where(Issue.series_id == series.id)) - } series.title = values.title or series.title series.sort_title = values.sort_title or series.sort_title series.description, series.year_start, series.year_end = ( @@ -433,52 +397,17 @@ async def _apply( series.series_type = SeriesType(values.series_type) if values.status is not None: series.status = SeriesStatus(values.status) - additions = 0 - for offset in range(0, len(prepared), 200): - pending = [] - for existing, metadata, number, text, issue_snapshot in prepared[offset : offset + 200]: - issue = existing_issues.get(existing.local_id) if existing else None - if issue is None: - issue = Issue( - series_id=series.id, - issue_number=number, - issue_number_text=text, - status=IssueStatus.WANTED if series.monitored else IssueStatus.SKIPPED, - issue_type=classify_issue_metadata(series.series_type, metadata.title)[1], - metadata_source=_metadata_label(source), - ) - session.add(issue) - additions += 1 - value = issue_snapshot.values - issue.title, issue.description = value.title, value.description - issue.release_date, issue.store_date = value.cover_date, value.store_date - issue.page_count, issue.cover_url = value.page_count, value.image_url - pending.append((issue, existing, metadata, issue_snapshot)) - await session.flush() - await attach_verified_identities( - session, - [ - _request( - bundle, metadata, _identities(metadata, MetadataEntityKind.ISSUE)[0], issue.id - ) - for issue, old, metadata, _ in pending - if old is None - ], - ) - for issue, old, metadata, _ in pending: - if old is None: - for crosswalk in _identities(metadata, MetadataEntityKind.ISSUE)[1:]: - await record_identity_observation( - session, _request(bundle, metadata, crosswalk, issue.id, observation=True) - ) - await save_metadata_baselines( - session, - [ - MetadataBaselineWrite(issue.id, item, old.baseline_revision if old else 0) - for issue, old, _, item in pending - ], - ) - series.issue_count = len(state.issues) + additions + created = await apply_issue_batch( + session, + state, + SourceIssueBatch( + bundle.series.source, bundle.series.external_id, bundle.issues, bundle.source_revision + ), + now, + complete=True, + replace_managed=replace_managed, + ) + series.issue_count = len(state.issues) + len(created) snapshot = _with_derived(snapshot, "issue_count", series.issue_count, now) origins = {item.field: item for item in snapshot.origins} status_origin = origins.get("status") @@ -498,4 +427,4 @@ async def _apply( await save_metadata_baselines( session, [MetadataBaselineWrite(series.id, snapshot, state.series.baseline_revision)] ) - return series + return SeriesCatalogRefresh(series, created) diff --git a/tests/integration/metadata_identity/test_issue_catalog_writer.py b/tests/integration/metadata_identity/test_issue_catalog_writer.py new file mode 100644 index 00000000..63eadda3 --- /dev/null +++ b/tests/integration/metadata_identity/test_issue_catalog_writer.py @@ -0,0 +1,306 @@ +"""Full and partial catalog writes share identity and ownership safeguards.""" + +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +from sqlalchemy import func, select + +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, IssueReaderState, LibraryFile, LibraryRoot, Series, User +from pullbox.models.issue import IssueStatus +from pullbox.models.library import FileFormat, LibraryFileStorageMode +from pullbox.models.metadata_identity import IssueExternalIdentity, IssueIdentityEvent +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_issue_catalog import ( + IssueCatalogConflictError, + SourceIssueBatch, + apply_issue_batch, +) +from pullbox.services.metadata_series_refresh import refresh_series_from_sources +from pullbox.services.metadata_series_refresh_state import read_series_refresh_state +from pullbox.services.metadata_writer_identity import metadata_write_scope +from tests.integration.metadata_identity.test_series_adoption import ( # noqa: F401 + bundle, + configured_sources, +) +from tests.integration.metadata_identity.test_series_refresh import ( + RefreshAdapter, + refresh_registry, + seed, +) + + +def batch(data, *, issues=None): + return SourceIssueBatch( + data.series.source, + data.series.external_id, + data.issues if issues is None else issues, + data.source_revision, + ) + + +async def apply(session, series_id, offered, **kwargs): + async with metadata_write_scope(session): + state = await read_series_refresh_state(session, series_id) + return await apply_issue_batch(session, state, offered, datetime.now(UTC), **kwargs) + + +@pytest.mark.parametrize("monitored", [False, True]) +async def test_partial_window_adds_only_its_new_identity_without_claiming_complete_sync( + identity_probe_db, monitored +): + _, factory, _ = identity_probe_db + series_id, issue_id, _ = await seed(factory, count=2) + data = bundle(numbers=("1", "2", "50-x")) + async with factory.begin() as session: + series = await session.get(Series, series_id) + series.monitored = monitored + async with factory() as session: + before = await read_series_refresh_state(session, series_id) + series = await session.get(Series, series_id) + times = (series.issue_catalog_last_checked_at, series.issue_catalog_last_synced_at) + created = await apply(session, series_id, batch(data, issues=data.issues[2:])) + assert len(created) == 1, "A partial catalog window must actually register new issues" + issue = await session.get(Issue, created[0]) + assert issue.issue_number_text == "50-X" + assert issue.status is (IssueStatus.WANTED if monitored else IssueStatus.SKIPPED) + assert (await session.get(Issue, issue_id)).status is IssueStatus.OWNED + assert (series.issue_catalog_last_checked_at, series.issue_catalog_last_synced_at) == times + assert series.path == "/reference/original" + after = await read_series_refresh_state(session, series_id) + assert after.series == before.series and after.checkpoints == before.checkpoints + assert after.issues[:2] == before.issues + assert (await load_metadata_baseline(session, Kind.ISSUE, created[0])).revision == 1 + await session.commit() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Issue)) == 3 + assert await apply(session, series_id, batch(data, issues=data.issues[2:])) == () + await session.commit() + + +async def test_partial_update_preserves_local_edits_manual_skips_and_owned_status( + identity_probe_db, +): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + async with factory.begin() as session: + issue = await session.get(Issue, issue_id) + issue.title = "My local title" + issue.integrity_status = "verified" + data.issues[0].title = "Provider title" + data.issues[0].description = "New provider description" + async with factory() as session: + assert await apply(session, series_id, batch(data)) == () + issue = await session.get(Issue, issue_id) + assert issue.description == "New provider description" + assert issue.title == "My local title" + assert issue.status is IssueStatus.OWNED and issue.manual_skip + assert issue.integrity_status == "verified" + await session.commit() + + +@pytest.mark.parametrize( + "conflict", + [ + "parent", + "parent_alias", + "source", + "revision", + "renumber", + "identity", + "duplicate_number", + "duplicate_id", + "missing", + ], +) +async def test_invalid_batch_cannot_partially_mutate_existing_metadata(identity_probe_db, conflict): + _, factory, _ = identity_probe_db + series_id, issue_id, _ = await seed(factory, count=2) + data = bundle(numbers=("1", "2", "3")) + data.issues[0].description = "Must not save" + offered = batch(data) + if conflict == "parent": + offered = replace(offered, series_external_id="999") + elif conflict == "parent_alias": + offered = replace(offered, series_external_id="042") + for issue in offered.issues: + issue.series_external_id = "042" + elif conflict == "source": + offered = replace(offered, source=Source.COMICVINE_API) + elif conflict == "revision": + offered = replace(offered, source_revision=9) + elif conflict == "renumber": + data.issues[1].issue_number_text = "50-o" + elif conflict == "identity": + data.issues[1].external_id = "999" + elif conflict == "duplicate_number": + data.issues[2].issue_number_text = "2" + elif conflict == "duplicate_id": + data.issues[2].external_id = data.issues[1].external_id + else: + offered = batch(data, issues=data.issues[:1]) + async with factory() as session: + with pytest.raises(IssueCatalogConflictError): + await apply(session, series_id, offered, complete=conflict == "missing") + await session.commit() + async with factory() as session: + assert (await session.get(Issue, issue_id)).description is None + assert await session.scalar(select(func.count()).select_from(Issue)) == 2 + assert (await load_metadata_baseline(session, Kind.ISSUE, issue_id)).revision == 1 + + +async def test_partial_batch_crosswalk_stays_observation_and_rollback_is_atomic(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + data = bundle(numbers=("1", "2")) + data.issues[1].cross_identities = [ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "901")] + async with factory() as session: + created = await apply(session, series_id, batch(data, issues=data.issues[1:])) + assert len(created) == 1 + assert (await session.get(Issue, created[0])).comicvine_id is None + claim = await session.scalar( + select(IssueExternalIdentity).where(IssueExternalIdentity.issue_id == created[0]) + ) + assert claim.identity_namespace is Namespace.METRON + event = await session.scalar( + select(IssueIdentityEvent).where( + IssueIdentityEvent.identity_namespace == Namespace.COMICVINE + ) + ) + assert event.verification_state.value == "observed" + await session.rollback() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Issue)) == 1 + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 1 + + +async def test_scheduled_receipt_does_not_count_issues_created_before_its_snapshot( + identity_probe_db, monkeypatch +): + from pullbox.providers.metadata import sources + from pullbox.services import metadata_series_refresh as refresh + from pullbox.services.metadata_scheduled_refresh import refresh_scheduled_series + + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + data = bundle(numbers=("1", "2")) + adapter = RefreshAdapter(data) + monkeypatch.setattr(sources, "metadata_sources", lambda: refresh_registry(adapter).factories) + original = refresh.read_series_refresh_state + inserted = False + + async def snapshot(session, local_id): + nonlocal inserted + if not inserted: + inserted = True + await session.rollback() + async with factory() as other: + await refresh_series_from_sources( + other, series_id, registry=refresh_registry(adapter) + ) + await other.commit() + return await original(session, local_id) + + monkeypatch.setattr(refresh, "read_series_refresh_state", snapshot) + async with factory() as session: + result = await refresh_scheduled_series(session, series_id) + assert result.added == 0, ( + "ID ranges cannot attribute another operation's issue to this refresh" + ) + assert not result.search_wanted + await session.commit() + + +@pytest.mark.parametrize("complete", [False, True]) +async def test_shared_writer_preserves_registered_artifact_and_private_reading_state( + identity_probe_db, tmp_path, complete +): + _, factory, _ = identity_probe_db + series_id, issue_id, data = await seed(factory) + path = tmp_path / "original.cbz" + path.write_bytes(b"original archive bytes") + now = datetime.now(UTC) + async with factory.begin() as session: + root = LibraryRoot(name="Reference library", path=str(tmp_path), allow_managed_writes=False) + user = User(username="catalog-reader", password_hash="not-a-real-password") + session.add_all([root, user]) + await session.flush() + file = LibraryFile( + file_path=str(path), + file_name=path.name, + file_size=path.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=now, + issue_id=issue_id, + library_root_id=root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + naming_snapshot={"original": "untouched"}, + source_signature={"size": path.stat().st_size}, + ) + reader = IssueReaderState( + user_id=user.id, + issue_id=issue_id, + last_page_index=9, + page_count=32, + content_revision="archive-revision", + last_opened_at=now, + progress_updated_at=now, + state_version=7, + want_to_read=True, + ) + session.add_all([file, reader]) + async with factory() as session: + data.issues[0].description = "New metadata only" + assert await apply(session, series_id, batch(data), complete=complete) == () + await session.commit() + async with factory() as session: + saved_file = await session.scalar(select(LibraryFile)) + saved_reader = await session.scalar(select(IssueReaderState)) + assert saved_file.issue_id == issue_id and saved_file.file_path == str(path) + assert saved_file.storage_mode is LibraryFileStorageMode.REFERENCED + assert saved_file.naming_snapshot == {"original": "untouched"} + assert saved_file.source_signature == {"size": path.stat().st_size} + assert saved_file.file_modified_at == now + assert saved_reader.issue_id == issue_id and saved_reader.last_page_index == 9 + assert saved_reader.page_count == 32 and saved_reader.state_version == 7 + assert saved_reader.content_revision == "archive-revision" + assert saved_reader.progress_updated_at == now and saved_reader.want_to_read + assert (await session.get(Issue, issue_id)).description == "New metadata only" + assert path.read_bytes() == b"original archive bytes" + + +async def test_empty_partial_window_is_not_a_full_membership_deletion(identity_probe_db): + _, factory, _ = identity_probe_db + series_id, _, data = await seed(factory) + async with factory() as session: + before = await read_series_refresh_state(session, series_id) + assert await apply(session, series_id, batch(data, issues=())) == () + assert await read_series_refresh_state(session, series_id) == before + await session.commit() + + +async def test_failed_issue_baseline_write_rolls_back_rows_and_identity_events( + identity_probe_db, monkeypatch +): + from pullbox.services import metadata_issue_catalog as writer + + _, factory, _ = identity_probe_db + series_id, _, _ = await seed(factory) + data = bundle(numbers=("1", "2")) + + async def fail(*_args): + raise RuntimeError("baseline rejected") + + monkeypatch.setattr(writer, "save_metadata_baselines", fail) + async with factory() as session: + with pytest.raises(RuntimeError, match="baseline rejected"): + await apply(session, series_id, batch(data, issues=data.issues[1:])) + await session.commit() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Issue)) == 1 + assert await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 1 + assert await session.scalar(select(func.count()).select_from(IssueIdentityEvent)) == 1 From 334054a1e4f477fd17a2d32c1403e65e7d65d27f Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 16:02:04 -0700 Subject: [PATCH 50/97] feat(metadata): sync daily issues through configured sources Use source-bound recent windows and atomic catalog checkpoints while preserving cadence, ownership, cancellation, and post-commit searches. Replace CV-only task mocks with source-aware database contracts and retain retry outcomes when fallback fails. --- docs/development/ARCHITECTURE_OVERVIEW.md | 27 +- .../services/metadata_catalog_checkpoints.py | 63 +- src/pullbox/services/metadata_daily_sync.py | 293 +++++ src/pullbox/tasks/metadata_task.py | 470 ++------ .../test_daily_issue_sync.py | 452 +++++++ tests/tasks/test_metadata_search_trigger.py | 1072 +++-------------- 6 files changed, 1064 insertions(+), 1313 deletions(-) create mode 100644 src/pullbox/services/metadata_daily_sync.py create mode 100644 tests/integration/metadata_identity/test_daily_issue_sync.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 6c1d85e8..cc8e0d4d 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -333,8 +333,8 @@ and truncation describe only that slice. A truncated modification window needs a full/bounded reconciliation before advancing its checkpoint. Empty local results still carry their immutable catalog generation. These reads do not use the full-page response cache, follow continuation links or attach identities. -Daily issue-sweep integration remains pending; existing cadence/full-sync rules -must be retained when that caller adopts this contract. +The daily issue sweep uses these windows with source-specific checkpoints and +retains the normal continuing/ended-series cadence and full-refresh deadline. Complete source-aware Add and refresh now persist source-bound catalog checkpoints with the same atomic library write. The checkpoint retains the @@ -342,9 +342,8 @@ source-policy and verified-identity revisions, exact parent and local generation Only proven live catalog reads establish the request-start boundary. Cached bundles of unknown age and Add of an existing series do not advance progress. Refresh revalidates captured checkpoints along with metadata and policies, so -an in-flight refresh cannot overwrite newer sync progress. Incremental consumers -must still handle overlap, generation changes and truncated modification windows; -this checkpoint storage does not itself implement daily source-aware sync. +an in-flight refresh cannot overwrite newer sync progress. Incremental advancement +preserves the full-sync date and commits with the corresponding issue writes. `services/metadata_issue_catalog.py` applies either complete membership or an issue-only batch to the caller's locked, revalidated read set. Full refresh uses @@ -353,8 +352,10 @@ timestamp. Both modes preserve issue ownership, local edits, private reading sta and registered artifacts while sharing native identity, crosswalk observation and canonical-baseline writes. The writer returns exact created issue IDs. Scheduled refresh uses that receipt, not an ID range that could include another operation's -new issues, when deciding whether to search after commit. Daily source-window -orchestration and atomic incremental checkpoint advancement remain pending. +new issues, when deciding whether to search after commit. Daily synchronization +uses the same writer, overlaps modification cursors by two minutes, and falls back +to full reconciliation for truncated modification windows, changed local catalog +generations, or publication slices that cannot account for known membership. `services/metadata_series_adoption.py` separates a complete server-side catalog fetch from transactional adoption. Traversal rejects partial pages, repeated @@ -680,8 +681,8 @@ initial upper bound in `SystemConfig`. Each batch handles at most 25 series and checks a two-minute budget between series; an individual series has a separate 15-minute timeout. Pending batches resume through hidden continuations, including after restart. Provider throttling pauses the sweep at its saved position instead -of repeatedly failing every remaining series. Series metadata writes are committed -before subsequent cover or issue-provider waits. +of repeatedly failing every remaining series. Provider reads happen before the +atomic library/progress write; optional cover work follows its commit. The scheduled metadata refresh selects verified identities backed by executable, configured sources, including native Metron and local ComicVine without an API key. It uses the same revision-checked writer as manual refresh but fills gaps @@ -689,9 +690,11 @@ only, preserving existing descriptive values and user overrides. Its metadata write and sweep checkpoint commit together before search scheduling or optional cover-cache work. Structured source failures preserve durable retry deadlines; disabling the last eligible source clears the continuation. -The separate daily issue sweep still uses the legacy ComicVine recent/full -catalog path; removing its ComicVine key stops that sweep and clears its -continuation. Source-aware recent-catalog integration remains required. +The daily issue sweep uses the same configured-source eligibility, with partial +windows where a valid source checkpoint permits them. Native and local sources +do not require a ComicVine API key. Failed sources retain their checkpoints; +if fallback also fails, earlier retryable outcomes retain the sweep's retry +deadline. Independent per-provider retry progress remains future work. Post-restore aftercare keeps its recovery marker while continuation batches remain active; it observes completion without retaining a database transaction between checks. Cancellation leaves the marker and sweep checkpoint available for the next startup. diff --git a/src/pullbox/services/metadata_catalog_checkpoints.py b/src/pullbox/services/metadata_catalog_checkpoints.py index f73bd3b1..f371cb1e 100644 --- a/src/pullbox/services/metadata_catalog_checkpoints.py +++ b/src/pullbox/services/metadata_catalog_checkpoints.py @@ -118,6 +118,58 @@ async def save_full_catalog_checkpoint( Never call with a publication slice or truncated modification window. The boundary is the live request start, not the time the final page was saved. """ + return await _save_catalog_checkpoint( + session, + series_id, + source=source, + source_revision=source_revision, + identity_revision=identity_revision, + external_id=external_id, + started_at=started_at, + source_updated_at=source_updated_at, + expected_revision=expected_revision, + ) + + +async def advance_catalog_checkpoint( + session: AsyncSession, + previous: CatalogCheckpoint, + *, + started_at: datetime, + source_updated_at: datetime | None = None, +) -> CatalogCheckpoint: + """Advance a fully applied bounded window without resetting its full-sync date. + + The caller must fall back to full reconciliation for truncated modification + windows or changed local generations, and commit its issue writes atomically. + """ + return await _save_catalog_checkpoint( + session, + previous.series_id, + source=previous.source, + source_revision=previous.source_revision, + identity_revision=previous.identity_revision, + external_id=previous.external_id, + started_at=started_at, + source_updated_at=source_updated_at, + expected_revision=previous.revision, + previous=previous, + ) + + +async def _save_catalog_checkpoint( + session: AsyncSession, + series_id: int, + *, + source: MetadataSource, + source_revision: int, + identity_revision: int, + external_id: str, + started_at: datetime, + source_updated_at: datetime | None, + expected_revision: int, + previous: CatalogCheckpoint | None = None, +) -> CatalogCheckpoint: if ( not isinstance(source, MetadataSource) or type(source_revision) is not int @@ -188,6 +240,14 @@ async def save_full_catalog_checkpoint( raise CatalogCheckpointConflictError( "An older catalog cannot replace newer progress." ) + if previous is not None and ( + row is None + or _snapshot(row) != previous + or generation != previous.source_updated_at + ): + raise CatalogCheckpointConflictError( + "Catalog progress or generation changed. Read the full catalog again." + ) if row is None: row = SeriesCatalogCheckpoint(series_id=series_id, source=source.value) session.add(row) @@ -195,7 +255,8 @@ async def save_full_catalog_checkpoint( row.identity_id, row.identity_revision = claim.id, claim.revision row.external_id = external_id row.revision = expected_revision + 1 - row.checked_at = row.full_synced_at = started_at + row.checked_at = started_at + row.full_synced_at = previous.full_synced_at if previous else started_at row.source_updated_at = generation await session.flush() return _snapshot(row) diff --git a/src/pullbox/services/metadata_daily_sync.py b/src/pullbox/services/metadata_daily_sync.py new file mode 100644 index 00000000..8534e63e --- /dev/null +++ b/src/pullbox/services/metadata_daily_sync.py @@ -0,0 +1,293 @@ +"""Bounded source-aware daily catalog checks with caller-owned progress.""" + +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.config import get_settings +from pullbox.core.exceptions import ValidationError +from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource +from pullbox.models import Issue, Series +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState, SeriesStatus +from pullbox.schemas.metadata_sources import ( + MetadataDomain, + RecentIssueWindow, + SourceCapability, + SourceOutcome, + SourceStatus, +) +from pullbox.services.cover_resolver import resolve_covers_dir +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_catalog_checkpoints import ( + CatalogCheckpoint, + advance_catalog_checkpoint, + load_catalog_checkpoint, +) +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_issue_catalog import SourceIssueBatch, apply_issue_batch +from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh +from pullbox.services.metadata_series_refresh import ( + SeriesRefreshError, + _with_derived, + refresh_series_catalog_from_sources, +) +from pullbox.services.metadata_series_refresh_state import ( + SERIES_FIELDS, + SeriesRefreshState, + read_series_refresh_state, +) +from pullbox.services.metadata_sources import load_source_runtime +from pullbox.services.metadata_writer_identity import metadata_write_scope + + +@dataclass(frozen=True) +class _Cadence: + monitored: bool + catalog_state: IssueCatalogState + metadata_refreshed: datetime | None + checked: datetime | None + synced: datetime | None + + +def _cadence(series: Series) -> _Cadence: + return _Cadence( + series.monitored, + series.issue_catalog_state, + series.metadata_last_refreshed, + series.issue_catalog_last_checked_at, + series.issue_catalog_last_synced_at, + ) + + +def _full_due( + cadence: _Cadence, checkpoint: CatalogCheckpoint | None, now: datetime, days: int +) -> bool: + return ( + checkpoint is None + or cadence.catalog_state is not IssueCatalogState.COMPLETE + or days <= 0 + or now - checkpoint.full_synced_at >= timedelta(days=days) + or cadence.metadata_refreshed is None + or now - cadence.metadata_refreshed >= timedelta(days=days) + ) + + +def _window_requires_full( + window: RecentIssueWindow, checkpoint: CatalogCheckpoint, state: SeriesRefreshState +) -> bool: + if ( + checkpoint.source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL} + and window.source_updated_at != checkpoint.source_updated_at + ): + return True + if window.scope == "modified_since": + return window.truncated + # Publication slices cannot hide a gap larger than the returned window. + known = { + ref.external_id + for item in state.issues + for ref in item.identities + if ref.namespace is checkpoint.source.identity_namespace + } + additions = {item.external_id for item in window.results} - known + return window.matched_total != len(known) + len(additions) + + +async def sync_scheduled_issue_catalog( + session: AsyncSession, series_id: int, *, refresh_days: int +) -> ScheduledSeriesRefresh: + """Read outside transactions, then save issues and progress for the task to commit.""" + if session.new or session.dirty or session.deleted: + raise SeriesRefreshError("Finish pending library changes before synchronizing issues.") + try: + async with asyncio.timeout(120): + return await _sync(session, series_id, refresh_days=refresh_days) + except SeriesRefreshError: + raise + except (ValueError, IntegrityError, ValidationError) as exc: + raise SeriesRefreshError( + "The issue catalog could not be synchronized safely. Review its match or retry." + ) from exc + except TimeoutError as exc: + raise SeriesRefreshError( + "Issue synchronization timed out. Retry later.", retry_after_seconds=300 + ) from exc + + +async def _sync( + session: AsyncSession, series_id: int, *, refresh_days: int +) -> ScheduledSeriesRefresh: + before = await read_series_refresh_state(session, series_id) + series = await session.get(Series, series_id) + assert series is not None + cadence = _cadence(series) + covers = await resolve_covers_dir(session) + enabled = get_settings().metadata_gcd_api_v2_enabled + registry = MetadataSourceRegistry( + await load_source_runtime(session, gcd_api_enabled=enabled), + gcd_api_enabled=enabled, + revalidate_reads=True, + total_timeout=60, + ) + known = {ref.namespace: ref.external_id for ref in before.series.identities} + sources = sorted( + ( + source + for source in registry.runtime + if source.identity_namespace in known + and registry.source_availability(source, SourceCapability.ISSUE_LIST) is None + and registry.source_availability(source, SourceCapability.SERIES_DETAILS) is None + ), + key=lambda source: ( + registry.runtime[source].policy.domain_priorities.get( + MetadataDomain.ISSUES, registry.runtime[source].policy.priority + ), + source.value, + ), + ) + checkpoints = { + source: await load_catalog_checkpoint(session, series_id, source) for source in sources + } + await session.rollback() + now = datetime.now(UTC) + interval = timedelta( + days=(14 if cadence.monitored else 30) + if before.series.values.status == SeriesStatus.ENDED.value + else 1 + ) + failures = [] + for source in sources: + checkpoint = checkpoints[source] + full = _full_due(cadence, checkpoint, now, refresh_days) + if not full and checkpoint is not None and now - checkpoint.checked_at < interval: + return ScheduledSeriesRefresh(0, False, None, covers) + full = ( + full or registry.source_availability(source, SourceCapability.RECENT_ISSUES) is not None + ) + if not full: + assert checkpoint is not None + started_at = datetime.now(UTC) + # Overlap the strict modified_gt boundary, including second-resolution providers. + since = checkpoint.checked_at.replace(microsecond=0) - timedelta(minutes=2) + fetched = await registry.recent_issues( + source, known[source.identity_namespace], since=since + ) + if fetched.status is not SourceStatus.OK or fetched.data is None: + outcome = SourceOutcome( + source=source, + status=fetched.status, + retry_after_seconds=fetched.retry_after_seconds, + ) + if fetched.status is SourceStatus.INCOMPATIBLE_RESPONSE: + raise SeriesRefreshError( + "The provider issue window is inconsistent. Review its match.", + outcomes=(outcome,), + ) + failures.append(outcome) + registry.runtime.pop(source) + continue + full = _window_requires_full(fetched.data, checkpoint, before) + if not full: + created = await _apply_window( + session, before, cadence, checkpoint, fetched.data, started_at + ) + return ScheduledSeriesRefresh( + len(created), cadence.monitored and bool(created), None, covers + ) + if full: + try: + result = await refresh_series_catalog_from_sources( + session, series_id, registry=registry, replace_managed=False + ) + except SeriesRefreshError as exc: + if not failures: + raise + # A failed fallback must not discard an earlier source's retry window. + raise SeriesRefreshError( + str(exc), + outcomes=(*failures, *exc.outcomes), + retry_after_seconds=exc.retry_after_seconds, + ) from exc + return ScheduledSeriesRefresh( + len(result.created_issue_ids), + result.series.monitored and bool(result.created_issue_ids), + result.series.cover_url, + covers, + ) + raise SeriesRefreshError( + "No configured source could supply the issue catalog. Check source status and retry.", + outcomes=tuple(failures), + ) + + +async def _apply_window( + session: AsyncSession, + before: SeriesRefreshState, + cadence: _Cadence, + checkpoint: CatalogCheckpoint, + window: RecentIssueWindow, + started_at: datetime, +) -> tuple[int, ...]: + series_id = before.series.local_id + async with metadata_write_scope(session): + await session.execute( + select(MetadataSourceConfig) + .order_by(MetadataSourceConfig.source) + .with_for_update(read=True) + ) + await session.execute(select(Series.id).where(Series.id == series_id).with_for_update()) + await session.execute( + select(Issue.id) + .where(Issue.series_id == series_id) + .order_by(Issue.id) + .with_for_update() + ) + current = await read_series_refresh_state(session, series_id) + series = await session.get(Series, series_id) + if current != before or series is None or _cadence(series) != cadence: + raise SeriesRefreshError( + "Library metadata or catalog progress changed. Retry synchronization." + ) + created = await apply_issue_batch( + session, + current, + SourceIssueBatch( + checkpoint.source, + checkpoint.external_id, + tuple(window.results), + checkpoint.source_revision, + ), + started_at, + ) + if created: + series.issue_count = len(current.issues) + len(created) + snapshot = assemble_metadata( + MetadataEntityKind.SERIES, + current.series.identities, + [], + current.policies, + now=started_at, + current=current.series.values, + previous=current.series.baseline, + overrides=current.series.overrides, + replace_managed=False, + fields=SERIES_FIELDS, + ) + snapshot = _with_derived(snapshot, "issue_count", series.issue_count, started_at) + await save_metadata_baselines( + session, + [MetadataBaselineWrite(series_id, snapshot, current.series.baseline_revision)], + ) + series.issue_catalog_last_checked_at = started_at + series.issue_catalog_error = None + await advance_catalog_checkpoint( + session, checkpoint, started_at=started_at, source_updated_at=window.source_updated_at + ) + await session.flush() + return created diff --git a/src/pullbox/tasks/metadata_task.py b/src/pullbox/tasks/metadata_task.py index ccd66643..03c98c85 100644 --- a/src/pullbox/tasks/metadata_task.py +++ b/src/pullbox/tasks/metadata_task.py @@ -1,269 +1,45 @@ -"""Metadata background tasks — syncs new issues and refreshes stale metadata. - -Two scheduled tasks: -- ``sync_new_issues`` (daily cron) — fetches issue lists for - ComicVine-backed series, creates new Issue records, and refreshes stale - series metadata changes (status, description, publisher). When new issues - are set to WANTED by monitoring criteria, a one-shot search is scheduled. -- ``refresh_metadata`` (cron, default 03:15) — re-fetches series metadata - when it exceeds the configured staleness threshold. -""" +"""Source-aware metadata sweeps with bounded batches and atomic library progress.""" from __future__ import annotations import asyncio import time -from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING import structlog -from sqlalchemy import func, or_, select +from sqlalchemy import or_, select from pullbox.config import PullboxSettings, get_settings from pullbox.core.exceptions import ProviderError -from pullbox.core.log_deduper import log_deduped_warning -from pullbox.core.sqlite_lock import is_sqlite_locked_error - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -from pullbox.core.comicvine_key import get_comicvine_api_key from pullbox.core.scheduler import TaskExecutionResult, get_scheduler +from pullbox.core.sqlite_lock import is_sqlite_locked_error from pullbox.database import get_session_factory -from pullbox.models.issue import Issue, IssueStatus -from pullbox.models.series import IssueCatalogState, Series, SeriesStatus -from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider +from pullbox.models.series import IssueCatalogState, Series +from pullbox.providers.metadata.comicvine import ComicVineError from pullbox.schemas.metadata_sources import SourceStatus +from pullbox.services.metadata_daily_sync import sync_scheduled_issue_catalog from pullbox.services.metadata_scheduled_refresh import ( ScheduledSeriesRefresh, refresh_scheduled_series, scheduled_series_eligibility, ) from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_artwork -from pullbox.services.metadata_service import MetadataService -from pullbox.tasks.metadata_sweep_state import load_sweep, save_sweep, schedule_sweep, start_sweep +from pullbox.tasks.metadata_sweep_state import save_sweep, schedule_sweep, start_sweep logger = structlog.get_logger(__name__) -_RECENT_ISSUE_SYNC_LIMIT = 100 _METADATA_BATCH_SIZE = 25 _METADATA_BATCH_SECONDS = 120.0 _METADATA_SERIES_SECONDS = 900.0 -_STANDARD_ISSUE_CHECK_INTERVAL = timedelta(hours=24) -_ENDED_MONITORED_ISSUE_CHECK_INTERVAL = timedelta(days=14) -_ENDED_UNMONITORED_ISSUE_CHECK_INTERVAL = timedelta(days=30) - - -async def _create_metadata_service( - api_key: str, - settings: PullboxSettings, - session: AsyncSession, -) -> MetadataService: - """Build a ComicVineProvider + MetadataService from an API key and settings.""" - from pullbox.services.cover_resolver import resolve_covers_dir - - provider = ComicVineProvider( - api_key=api_key, - rate_limit=settings.comicvine_rate_limit, - ) - covers_dir = await resolve_covers_dir(session) - return MetadataService( - provider, - covers_dir=covers_dir, - refresh_days=settings.metadata_refresh_days, - ) - - -@dataclass -class _SeriesSnapshot: - """Snapshot of series fields for change detection.""" - - status: str - description: str | None - publisher_id: int | None - - -def _take_snapshot(series: Series) -> _SeriesSnapshot: - """Capture current series metadata for later comparison.""" - return _SeriesSnapshot( - status=str(series.status), - description=series.description, - publisher_id=series.publisher_id, - ) - - -def _detect_changes( - series: Series, - before: _SeriesSnapshot, - log: structlog.stdlib.BoundLogger, -) -> tuple[bool, bool]: - """Compare series against its snapshot. Returns (status_changed, metadata_changed).""" - status_changed = False - metadata_changed = False - - current_status = str(series.status) - if current_status != before.status: - status_changed = True - log.info( - "series_status_changed", - old_status=before.status, - new_status=current_status, - ) - - if series.description != before.description: - metadata_changed = True - log.debug("series_description_updated") - - if series.publisher_id != before.publisher_id: - metadata_changed = True - log.debug("series_publisher_updated") - - return status_changed, metadata_changed - - -def _metadata_refresh_due(series: Series, refresh_days: int) -> bool: - """Return True when series-level metadata is missing or stale.""" - refreshed = series.metadata_last_refreshed - if refreshed is None: - return True - if refreshed.tzinfo is None: - refreshed = refreshed.replace(tzinfo=UTC) - return (datetime.now(UTC) - refreshed).days >= refresh_days def _metadata_refresh_days(settings: PullboxSettings) -> int: - """Read metadata refresh days defensively for tests and runtime overrides.""" try: return int(settings.metadata_refresh_days) except (TypeError, ValueError): return 30 -def _can_bootstrap_complete_catalog(series: Series, local_issue_count: int) -> bool: - """Return True when a legacy complete catalog can safely start with recent sync.""" - provider_issue_count = int(series.issue_count or 0) - return ( - series.issue_catalog_state == IssueCatalogState.COMPLETE - and series.issue_catalog_last_synced_at is None - and provider_issue_count > 0 - and local_issue_count >= provider_issue_count - ) - - -def _issue_catalog_full_sync_due( - series: Series, - refresh_days: int, - *, - local_issue_count: int = 0, -) -> bool: - """Return True when scheduled issue sync should fetch the full issue list.""" - if refresh_days <= 0: - return True - if series.issue_catalog_state != IssueCatalogState.COMPLETE: - return True - synced = series.issue_catalog_last_synced_at - if synced is None: - return not _can_bootstrap_complete_catalog(series, local_issue_count) - if synced.tzinfo is None: - synced = synced.replace(tzinfo=UTC) - return (datetime.now(UTC) - synced).days >= refresh_days - - -def _issue_check_interval_for_series(series: Series) -> timedelta: - """Return the normal issue-check cadence for a complete catalog.""" - if series.status == SeriesStatus.ENDED: - if series.monitored: - return _ENDED_MONITORED_ISSUE_CHECK_INTERVAL - return _ENDED_UNMONITORED_ISSUE_CHECK_INTERVAL - return _STANDARD_ISSUE_CHECK_INTERVAL - - -def _issue_catalog_check_due(series: Series, *, now: datetime | None = None) -> bool: - """Return True when a series should spend a ComicVine request on issue checks.""" - if series.issue_catalog_state != IssueCatalogState.COMPLETE: - return True - - checked = series.issue_catalog_last_checked_at - if checked is None: - return True - if checked.tzinfo is None: - checked = checked.replace(tzinfo=UTC) - - current_time = now or datetime.now(UTC) - interval = _issue_check_interval_for_series(series) - return current_time - checked >= interval - - -async def _sync_issue_catalog_for_series( - metadata_svc: MetadataService, - session: AsyncSession, - series: Series, - *, - full_refresh_days: int, - local_issue_count: int = 0, -) -> tuple[list[Issue], str]: - """Sync one series' issue catalog using full or recent ComicVine issue fetches.""" - bootstrap_complete_catalog = _can_bootstrap_complete_catalog(series, local_issue_count) - checked_at = datetime.now(UTC) - if _issue_catalog_full_sync_due( - series, - full_refresh_days, - local_issue_count=local_issue_count, - ): - created = await metadata_svc.fetch_issues_for_series(session, series.id) - mode = "full" - series.issue_catalog_last_synced_at = checked_at - series.issue_catalog_last_checked_at = checked_at - else: - created = await metadata_svc.fetch_recent_issues_for_series( - session, - series.id, - limit=_RECENT_ISSUE_SYNC_LIMIT, - ) - mode = "recent" - series.issue_catalog_last_checked_at = checked_at - if bootstrap_complete_catalog: - series.issue_catalog_last_synced_at = checked_at - - series.issue_catalog_state = IssueCatalogState.COMPLETE - series.issue_catalog_error = None - return created, mode - - -async def _sync_one_series( - metadata_svc: MetadataService, - session: AsyncSession, - series: Series, - *, - refresh_days: int, - local_issue_count: int, -) -> tuple[list[Issue], str, bool, bool]: - before = _take_snapshot(series) - log = logger.bind(series_id=series.id, title=series.title) - status_changed = metadata_changed = False - if series.comicvine_id and _metadata_refresh_due(series, refresh_days): - await metadata_svc.fetch_series(session, series.comicvine_id, download_cover=False) - # Provider waits must never retain the publisher/series writer lock. - await session.commit() - await session.refresh(series) - status_changed, metadata_changed = _detect_changes(series, before, log) - if series.cover_url: - await metadata_svc.download_series_cover(series, series.cover_url) - await session.commit() - - if not _issue_catalog_check_due(series): - return [], "skipped", status_changed, metadata_changed - created, mode = await _sync_issue_catalog_for_series( - metadata_svc, - session, - series, - full_refresh_days=refresh_days, - local_issue_count=local_issue_count, - ) - return created, mode, status_changed, metadata_changed - - def _provider_pause_seconds(exc: Exception) -> float | None: if is_sqlite_locked_error(exc): return 60 @@ -309,33 +85,10 @@ def _provider_pause_seconds(exc: Exception) -> float | None: async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: settings = get_settings() factory = get_session_factory() - series_to_search: list[int] = [] started = time.monotonic() async with factory() as session: - source_refresh = task_id == "refresh_metadata" - api_key = None if source_refresh else await get_comicvine_api_key(session) - if not source_refresh and not api_key: - state = await load_sweep(session, task_id) - if state.active: - state.active = False - state.retry_at = 0 - await save_sweep(session, task_id, state) - await session.commit() - schedule_sweep(task_id, state) - log_deduped_warning( - logger, - f"{task_id}_missing_comicvine_key", - key=f"{task_id}_missing_comicvine_key", - action_required="Configure a ComicVine API key to enable metadata sync.", - ) - return TaskExecutionResult(status="completed") - - eligible = ( - await scheduled_series_eligibility( - session, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled - ) - if source_refresh - else Series.comicvine_id.isnot(None) + eligible = await scheduled_series_eligibility( + session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled ) state = await start_sweep(session, task_id, eligible=eligible) refresh_days = _metadata_refresh_days(settings) @@ -343,12 +96,12 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: eligible, Series.id > state.cursor, Series.id <= state.upper_bound, + Series.issue_catalog_state != IssueCatalogState.HYDRATING, ] if task_id == "refresh_metadata": predicates.extend( [ Series.monitored.is_(True), - Series.issue_catalog_state != IssueCatalogState.HYDRATING, or_( Series.metadata_last_refreshed.is_(None), Series.metadata_last_refreshed @@ -357,14 +110,12 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: ] ) ids = list( - ( - await session.scalars( - select(Series.id) - .where(*predicates) - .order_by(Series.id) - .limit(_METADATA_BATCH_SIZE + 1) - ) - ).all() + await session.scalars( + select(Series.id) + .where(*predicates) + .order_by(Series.id) + .limit(_METADATA_BATCH_SIZE + 1) + ) ) if not ids: state.active = False @@ -377,127 +128,86 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: schedule_sweep(task_id, state) if state.retry_at > datetime.now(UTC).timestamp(): return TaskExecutionResult(status="waiting") - - counts = { - int(series_id): int(count) - for series_id, count in ( - await session.execute( - select(Issue.series_id, func.count(Issue.id)) - .where(Issue.series_id.in_(ids[:_METADATA_BATCH_SIZE])) - .group_by(Issue.series_id) - ) - ).all() - } - metadata_svc = ( - await _create_metadata_service(api_key, settings, session) if api_key else None - ) await session.commit() processed = failed = new_issues = 0 paused = False - try: - for series_id in ids[:_METADATA_BATCH_SIZE]: - if processed and time.monotonic() - started >= _METADATA_BATCH_SECONDS: - break - series = await session.get(Series, series_id) - if series is None: - state.cursor = series_id - await save_sweep(session, task_id, state) - await session.commit() - processed += 1 - continue - try: - previous_cursor = state.cursor - search_after_commit = False - refreshed: ScheduledSeriesRefresh | None = None - async with asyncio.timeout(_METADATA_SERIES_SECONDS): - if task_id == "refresh_metadata": - refreshed = await refresh_scheduled_series(session, series_id) - search_after_commit = refreshed.search_wanted - else: - assert metadata_svc is not None - created, _mode, _sc, _mc = await _sync_one_series( - metadata_svc, - session, - series, - refresh_days=refresh_days, - local_issue_count=counts.get(series_id, 0), - ) - new_issues += len(created) - if created and series.monitored: - wanted = False - for issue in created: - if issue.status == IssueStatus.SKIPPED: - issue.status = IssueStatus.WANTED - wanted = True - if wanted: - search_after_commit = True - state.cursor = series_id - state.retry_at = 0 - await save_sweep(session, task_id, state) - await session.commit() - if search_after_commit and not source_refresh: - series_to_search.append(series_id) - processed += 1 - except Exception as exc: - refreshed = None - await session.rollback() - state.cursor = previous_cursor - # Rollback expires ORM objects; use the stable ID, not their fields. - pause_seconds = _provider_pause_seconds(exc) - if pause_seconds is not None: - state.retry_at = ( - datetime.now(UTC) + timedelta(seconds=pause_seconds) - ).timestamp() - await save_sweep(session, task_id, state) - await session.commit() - paused = True - logger.warning( - "metadata_sweep_paused", - task_id=task_id, - series_id=series_id, - retry_seconds=pause_seconds, - failure_type=type(exc).__name__, + for series_id in ids[:_METADATA_BATCH_SIZE]: + if processed and time.monotonic() - started >= _METADATA_BATCH_SECONDS: + break + series = await session.get(Series, series_id) + if series is None: + state.cursor = series_id + await save_sweep(session, task_id, state) + await session.commit() + processed += 1 + continue + previous_cursor = state.cursor + refreshed: ScheduledSeriesRefresh | None = None + try: + async with asyncio.timeout(_METADATA_SERIES_SECONDS): + refreshed = ( + await refresh_scheduled_series(session, series_id) + if task_id == "refresh_metadata" + else await sync_scheduled_issue_catalog( + session, series_id, refresh_days=refresh_days ) - break - failed += 1 - processed += 1 - state.cursor = series_id + ) + state.cursor = series_id + state.retry_at = 0 + await save_sweep(session, task_id, state) + await session.commit() + processed += 1 + except Exception as exc: + refreshed = None + await session.rollback() + state.cursor = previous_cursor + pause_seconds = _provider_pause_seconds(exc) + if pause_seconds is not None: + state.retry_at = ( + datetime.now(UTC) + timedelta(seconds=pause_seconds) + ).timestamp() await save_sweep(session, task_id, state) await session.commit() - if isinstance(exc, SeriesRefreshError): - logger.warning( - f"{task_id}_series_failed", - series_id=series_id, - reason=str(exc), - source_statuses={ - item.source.value: item.status.value for item in exc.outcomes - }, - ) - else: - logger.exception(f"{task_id}_series_failed", series_id=series_id) - - # Artwork is optional and cannot roll back the committed cursor - # or postpone searches for newly committed wanted issues. - if refreshed is not None: - new_issues += refreshed.added - if refreshed.search_wanted: - _schedule_new_issue_search(series_id) - if refreshed.cover_url: - await refresh_series_artwork( - session, series_id, refreshed.cover_url, refreshed.covers - ) - - state.active = paused or processed < len(ids) - await save_sweep(session, task_id, state) - await session.commit() - finally: - close = getattr(metadata_svc, "close", None) - if close is not None: - await close() - - if series_to_search: - for sid in series_to_search: - _schedule_new_issue_search(sid) + paused = True + logger.warning( + "metadata_sweep_paused", + task_id=task_id, + series_id=series_id, + retry_seconds=pause_seconds, + failure_type=type(exc).__name__, + ) + break + failed += 1 + processed += 1 + state.cursor = series_id + await save_sweep(session, task_id, state) + await session.commit() + if isinstance(exc, SeriesRefreshError): + logger.warning( + f"{task_id}_series_failed", + series_id=series_id, + reason=str(exc), + source_statuses={ + item.source.value: item.status.value for item in exc.outcomes + }, + ) + else: + logger.exception(f"{task_id}_series_failed", series_id=series_id) + + # Search cannot precede the atomic issue/checkpoint/cursor commit. + # Optional artwork never postpones wanted searches or rewinds progress. + if refreshed is not None: + new_issues += refreshed.added + if refreshed.search_wanted: + _schedule_new_issue_search(series_id) + if refreshed.cover_url: + await refresh_series_artwork( + session, series_id, refreshed.cover_url, refreshed.covers + ) + + state.active = paused or processed < len(ids) + await save_sweep(session, task_id, state) + await session.commit() schedule_sweep(task_id, state) logger.info( f"{task_id}_batch_complete", diff --git a/tests/integration/metadata_identity/test_daily_issue_sync.py b/tests/integration/metadata_identity/test_daily_issue_sync.py new file mode 100644 index 00000000..8abbf227 --- /dev/null +++ b/tests/integration/metadata_identity/test_daily_issue_sync.py @@ -0,0 +1,452 @@ +"""Daily sync uses real configured source reads, canonical writes and sweep progress.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select, update + +from pullbox.core.comicvine_key import save_comicvine_api_key +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, Series, SeriesCatalogCheckpoint +from pullbox.models.issue import IssueStatus +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState, SeriesStatus +from pullbox.schemas.metadata_sources import MetadataFetch, RecentIssueWindow, SourceStatus +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_catalog_checkpoints import ( + load_catalog_checkpoint, + save_full_catalog_checkpoint, +) +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.tasks import metadata_task +from pullbox.tasks.metadata_sweep_state import load_sweep +from tests.integration.metadata_identity.test_scheduled_series_refresh import ( # noqa: F401 + add_series, + configured_sources, + offer, + scheduled, +) +from tests.integration.metadata_identity.test_series_adoption import bundle +from tests.integration.metadata_identity.test_series_refresh import RefreshAdapter +from tests.unit.test_metadata_source_reads import registry + + +class DailyAdapter(RefreshAdapter): + def __init__(self, data): + super().__init__(data) + self.scope = "modified_since" + self.recent_failure = None + self.recent_wait = None + self.recent_started = asyncio.Event() + self.window = None + + async def recent_issues(self, external_id, *, since): + assert self.session is None or not self.session.in_transaction() + self.calls.append(("recent", external_id, since)) + self.recent_started.set() + if self.recent_wait: + await self.recent_wait.wait() + if self.recent_failure: + raise MetadataSourceError(self.recent_failure, retry_after_seconds=720) + rows = self.data.issues if self.scope == "recent_publication" else self.data.issues[1:] + rows = [ + row.model_copy(update={"source_updated_at": datetime.now(UTC)}) + if self.scope == "modified_since" + else row + for row in rows + ] + window = self.window or RecentIssueWindow( + results=rows[:100], + matched_total=len(rows), + truncated=len(rows) > 100, + scope=self.scope, + since=since if self.scope == "modified_since" else None, + source_updated_at=self.data.series.source_updated_at, + ) + return MetadataFetch(status=SourceStatus.OK, data=window) + + +@pytest.fixture +async def daily(scheduled, monkeypatch): # noqa: F811 - imported pytest fixture + from pullbox.providers.metadata import sources + + factory, _, scheduler = scheduled + adapter = DailyAdapter(bundle(numbers=("1", "2"))) + monkeypatch.setattr(sources, "metadata_sources", lambda: registry(adapter).factories) + return factory, adapter, scheduler + + +async def checkpoint(factory, series_id, data, *, days=2, full_days=None): + started = datetime.now(UTC) - timedelta(days=days) + async with factory.begin() as session: + saved = await save_full_catalog_checkpoint( + session, + series_id, + source=data.series.source, + source_revision=1, + identity_revision=1, + external_id=data.series.external_id, + started_at=started, + source_updated_at=data.series.source_updated_at, + ) + if full_days is not None: + await session.execute( + update(SeriesCatalogCheckpoint).values( + full_synced_at=datetime.now(UTC) - timedelta(days=full_days) + ) + ) + series = await session.get(Series, series_id) + series.issue_catalog_last_checked_at = started + series.issue_catalog_last_synced_at = started + return saved + + +async def test_daily_native_full_sync_without_cv_key_commits_before_search(daily, monkeypatch): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + offer(adapter, data) + search = [] + original = metadata_task._schedule_new_issue_search + + def schedule(identifier): + search.append(identifier) + original(identifier) + + monkeypatch.setattr(metadata_task, "_schedule_new_issue_search", schedule) + result = await metadata_task.sync_new_issues() + assert adapter.calls, "Native daily synchronization must not require a ComicVine API key" + assert result.status == "completed" and search == [series_id] + async with factory() as session: + series = await session.get(Series, series_id) + assert series.issue_count == 2 and series.path == "/read-only/original" + issues = list(await session.scalars(select(Issue).order_by(Issue.id))) + assert issues[0].status is IssueStatus.OWNED and issues[0].manual_skip + assert issues[1].issue_number_text == "50-X" and issues[1].status is IssueStatus.WANTED + assert await load_catalog_checkpoint(session, series_id, Source.METRON_API) + state = await load_sweep(session, "sync_new_issues") + assert state.cursor == series_id and not state.active + scheduler._scheduler.add_job.assert_called_once() + + +async def test_truncated_modification_window_requires_complete_reconciliation(daily): + factory, adapter, _ = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + rows = tuple( + data.issues[0].model_copy( + update={ + "external_id": str(int(data.issues[0].external_id) + i), + "issue_number_text": str(i + 1), + "issue_number_key": str(i + 1), + } + ) + for i in range(102) + ) + adapter.data = replace( + data, + series=data.series.model_copy(update={"issue_count": 102}), + issues=rows, + catalog_total=102, + ) + await metadata_task.sync_new_issues() + assert [call[0] for call in adapter.calls] == ["recent", "series", "issues", "issues"] + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 102 + saved = await load_catalog_checkpoint(session, series_id, Source.METRON_API) + assert ( + saved.full_synced_at > old.full_synced_at and saved.full_synced_at == saved.checked_at + ) + + +async def test_empty_modified_window_updates_progress_without_rewriting_metadata(daily): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + adapter.data = data + await metadata_task.sync_new_issues() + assert [call[0] for call in adapter.calls] == ["recent"] + async with factory() as session: + assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 + saved = await load_catalog_checkpoint(session, series_id, Source.METRON_API) + assert saved.revision == 2 and saved.full_synced_at == old.full_synced_at + scheduler._scheduler.add_job.assert_not_called() + + +@pytest.mark.parametrize( + "failure", + [ + SourceStatus.RATE_LIMITED, + SourceStatus.AUTHENTICATION_FAILED, + SourceStatus.TIMEOUT, + SourceStatus.UNAVAILABLE, + ], +) +async def test_daily_window_failure_preserves_issue_checkpoint_and_durable_retry(daily, failure): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + offer(adapter, data) + adapter.recent_failure = failure + result = await metadata_task.sync_new_issues() + assert result.status == "waiting" + async with factory() as session: + assert await load_catalog_checkpoint(session, series_id, Source.METRON_API) == old + assert (await session.get(Series, series_id)).issue_count == 1 + state = await load_sweep(session, "sync_new_issues") + assert state.cursor == 0 and state.retry_at > datetime.now(UTC).timestamp() + await metadata_task.sync_new_issues() + assert len(adapter.calls) == 1 + scheduler._scheduler.add_job.assert_not_called() + + +@pytest.mark.parametrize("change", ["policy", "identity", "baseline", "monitor", "checkpoint"]) +async def test_inflight_daily_change_rolls_back_entire_window(daily, change): + from pullbox.models.metadata_baseline import SeriesMetadataBaseline + from pullbox.models.metadata_identity import SeriesExternalIdentity + + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + offer(adapter, data) + adapter.recent_wait = asyncio.Event() + task = asyncio.create_task(metadata_task.sync_new_issues()) + try: + await asyncio.wait_for(adapter.recent_started.wait(), 2) + async with factory.begin() as session: + if change == "policy": + await session.execute(update(MetadataSourceConfig).values(revision=2)) + elif change == "identity": + await session.execute(update(SeriesExternalIdentity).values(revision=2)) + elif change == "baseline": + await session.execute(update(SeriesMetadataBaseline).values(revision=2)) + elif change == "monitor": + (await session.get(Series, series_id)).monitored = False + else: + await session.execute(update(SeriesCatalogCheckpoint).values(revision=2)) + adapter.recent_wait.set() + await asyncio.wait_for(task, 5) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 1 + saved = await session.scalar(select(SeriesCatalogCheckpoint)) + assert saved.checked_at == old.checked_at and saved.full_synced_at == old.full_synced_at + scheduler._scheduler.add_job.assert_not_called() + + +async def test_daily_source_reads_release_database_transaction(daily): + from pullbox.services.metadata_daily_sync import sync_scheduled_issue_catalog + + factory, adapter, _ = daily + series_id, data = await add_series(factory, fresh=True) + await checkpoint(factory, series_id, data) + offer(adapter, data) + async with factory() as session: + adapter.session = session + result = await sync_scheduled_issue_catalog(session, series_id, refresh_days=30) + assert result.added == 1 + await session.rollback() + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 1 + + +@pytest.mark.parametrize("fallback_fails", [False, True]) +async def test_daily_source_fallback_preserves_failed_source_progress( + daily, monkeypatch, fallback_fails +): + from pullbox.providers.metadata import sources + + factory, _, scheduler = daily + async with factory.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + for source, priority in [(Source.COMICVINE_API, 0), (Source.COMICVINE_LOCAL, 1)]: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .values(enabled=True, domain_priorities={"issues": priority}) + ) + await save_comicvine_api_key(session, "daily-test-key") + series_id, local_data = await add_series(factory, fresh=True, source=Source.COMICVINE_LOCAL) + if not fallback_fails: + await checkpoint(factory, series_id, local_data) + api_data = replace( + local_data, + series=local_data.series.model_copy(update={"source": Source.COMICVINE_API}), + issues=tuple( + row.model_copy(update={"source": Source.COMICVINE_API}) for row in local_data.issues + ), + ) + old_api = await checkpoint(factory, series_id, api_data) + local = DailyAdapter(local_data) + local.source, local.scope = Source.COMICVINE_LOCAL, "recent_publication" + offer(local, local_data) + if fallback_fails: + local.profile_failure = SourceStatus.NOT_FOUND + api = DailyAdapter(api_data) + api.source, api.scope = Source.COMICVINE_API, "recent_publication" + api.recent_failure = SourceStatus.RATE_LIMITED + factories = {**registry(api).factories, **registry(local).factories} + monkeypatch.setattr(sources, "metadata_sources", lambda: factories) + result = await metadata_task.sync_new_issues() + assert result.status == ("waiting" if fallback_fails else "completed") + assert [call[0] for call in api.calls] == ["recent"] + assert [call[0] for call in local.calls] == (["series"] if fallback_fails else ["recent"]) + async with factory() as session: + assert await load_catalog_checkpoint(session, series_id, Source.COMICVINE_API) == old_api + assert (await session.get(Series, series_id)).issue_count == (1 if fallback_fails else 2) + assert scheduler._scheduler.add_job.call_count == int(not fallback_fails) + + +async def test_daily_partial_window_advances_only_checked_floor_and_replays_safely(daily): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + offer(adapter, data) + before = datetime.now(UTC) + await metadata_task.sync_new_issues() + assert [call[0] for call in adapter.calls] == ["recent"] + since = adapter.calls[0][2] + assert since < old.checked_at and old.checked_at - since <= timedelta(minutes=5) + async with factory() as session: + saved = await load_catalog_checkpoint(session, series_id, Source.METRON_API) + assert saved.revision == 2 and saved.full_synced_at == old.full_synced_at + assert before <= saved.checked_at <= datetime.now(UTC) + series = await session.get(Series, series_id) + assert series.issue_catalog_last_synced_at == old.full_synced_at + assert series.issue_count == 2 and series.issue_catalog_state is IssueCatalogState.COMPLETE + baseline = await load_metadata_baseline(session, Kind.SERIES, series_id) + assert baseline.snapshot.values.issue_count == 2 + await metadata_task.sync_new_issues() + assert len(adapter.calls) == 1 + scheduler._scheduler.add_job.assert_called_once() + + +@pytest.mark.parametrize("reason", ["stale", "invalidated", "partial", "metadata"]) +async def test_daily_requires_full_read_for_stale_or_unproven_catalog(daily, reason): + factory, adapter, _ = daily + series_id, data = await add_series(factory, fresh=reason != "metadata") + await checkpoint(factory, series_id, data, full_days=60 if reason == "stale" else None) + async with factory.begin() as session: + if reason == "invalidated": + await session.execute(update(MetadataSourceConfig).values(revision=2)) + elif reason == "partial": + (await session.get(Series, series_id)).issue_catalog_state = IssueCatalogState.PARTIAL + offer(adapter, data) + await metadata_task.sync_new_issues() + assert [call[0] for call in adapter.calls] == ["series", "issues"] + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 2 + + +@pytest.mark.parametrize("source", [Source.COMICVINE_LOCAL, Source.COMICVINE_API]) +@pytest.mark.parametrize("changed", [False, True]) +async def test_daily_cv_generation_and_publication_slice(daily, source, changed): + factory, adapter, _ = daily + async with factory.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .values(enabled=True) + ) + if source is Source.COMICVINE_API: + await save_comicvine_api_key(session, "daily-test-key") + series_id, data = await add_series(factory, fresh=True, source=source) + await checkpoint(factory, series_id, data) + adapter.source = source + adapter.scope = "recent_publication" + offer(adapter, data) + if changed and source is Source.COMICVINE_LOCAL: + newer = datetime.now(UTC) + for row in (adapter.data.series, *adapter.data.issues): + row.source_updated_at = newer + if changed and source is Source.COMICVINE_API: + adapter.window = RecentIssueWindow( + results=[], matched_total=0, scope="recent_publication", truncated=False + ) + await metadata_task.sync_new_issues() + assert [call[0] for call in adapter.calls] == ( + ["recent", "series", "issues"] if changed else ["recent"] + ) + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 2 + + +@pytest.mark.parametrize( + "status,monitored,days,due", + [ + (SeriesStatus.CONTINUING, True, 0, False), + (SeriesStatus.CONTINUING, False, 2, True), + (SeriesStatus.ENDED, True, 2, False), + (SeriesStatus.ENDED, True, 15, True), + (SeriesStatus.ENDED, False, 15, False), + (SeriesStatus.ENDED, False, 31, True), + ], +) +async def test_daily_preserves_cadence_and_unmonitored_issue_status( + daily, status, monitored, days, due +): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True, monitored=monitored) + await checkpoint(factory, series_id, data, days=days) + async with factory.begin() as session: + (await session.get(Series, series_id)).status = status + offer(adapter, data) + await metadata_task.sync_new_issues() + assert bool(adapter.calls) is due + if due: + async with factory() as session: + issue = await session.scalar(select(Issue).where(Issue.issue_number_text == "50-X")) + assert issue.status is (IssueStatus.WANTED if monitored else IssueStatus.SKIPPED) + assert scheduler._scheduler.add_job.call_count == int(due and monitored) + + +async def test_daily_failed_sweep_commit_does_not_publish_partial_metadata_or_search( + daily, monkeypatch +): + factory, adapter, scheduler = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + offer(adapter, data) + original = metadata_task.save_sweep + + async def fail(session, task_id, state): + if state.cursor == series_id: + raise RuntimeError("sweep rejected") + await original(session, task_id, state) + + monkeypatch.setattr(metadata_task, "save_sweep", fail) + with pytest.raises(RuntimeError, match="sweep rejected"): + await metadata_task.sync_new_issues() + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 1 + assert await load_catalog_checkpoint(session, series_id, Source.METRON_API) == old + scheduler._scheduler.add_job.assert_not_called() + + +async def test_daily_cancelled_window_leaves_original_checkpoint_and_resumes(daily): + factory, adapter, _ = daily + series_id, data = await add_series(factory, fresh=True) + old = await checkpoint(factory, series_id, data) + offer(adapter, data) + adapter.recent_wait = asyncio.Event() + task = asyncio.create_task(metadata_task.sync_new_issues()) + try: + await asyncio.wait_for(adapter.recent_started.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + assert await load_catalog_checkpoint(session, series_id, Source.METRON_API) == old + assert (await load_sweep(session, "sync_new_issues")).cursor == 0 + adapter.recent_wait = None + await metadata_task.sync_new_issues() + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 2 diff --git a/tests/tasks/test_metadata_search_trigger.py b/tests/tasks/test_metadata_search_trigger.py index df10a48b..ee5d0735 100644 --- a/tests/tasks/test_metadata_search_trigger.py +++ b/tests/tasks/test_metadata_search_trigger.py @@ -1,68 +1,73 @@ -"""Tests for the new-issue search trigger in sync_new_issues. - -Verifies: -- When sync finds new issues and series is monitored, search is scheduled -- No search triggered for unmonitored series -- No search triggered if sync finds issues but none set to WANTED -- New issues have monitoring criteria applied (SKIPPED → WANTED) - -Run: - pytest tests/tasks/test_metadata_search_trigger.py -v -""" - -from __future__ import annotations +"""Sweep coordination contracts; real source/cadence coverage lives in metadata_identity.""" import asyncio import contextlib -import os -import sys -from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from pullbox.models import Base -from pullbox.models.issue import Issue, IssueStatus -from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models import Base, Series +from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh +from pullbox.services.metadata_series_refresh import SeriesRefreshError +from pullbox.tasks import metadata_task +from pullbox.tasks.metadata_sweep_state import MetadataSweep, load_sweep, save_sweep -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +_MOD = "pullbox.tasks.metadata_task" -if TYPE_CHECKING: - from collections.abc import AsyncGenerator, Generator -os.environ.setdefault("PULLBOX_SECRET_KEY", "test-secret-key-for-metadata-search") +@pytest.fixture +async def restore_db_factory(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'restore.db'}") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() -_MOD = "pullbox.tasks.metadata_task" +async def _create_series(factory, *, comicvine_id): + async with factory.begin() as session: + series = Series( + comicvine_id=comicvine_id, title="Example", sort_title="Example", monitored=True + ) + session.add(series) + await session.flush() + return series.id -@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) -async def test_missing_key_stops_active_metadata_continuation(db_factory, task_id): - from pullbox.tasks import metadata_task - from pullbox.tasks.metadata_sweep_state import MetadataSweep, load_sweep, save_sweep - async with db_factory() as session: - await save_sweep( - session, task_id, MetadataSweep(cursor=1, upper_bound=3, retry_at=100, active=True) - ) - await session.commit() - scheduler = _make_scheduler() - svc = _make_metadata_svc([]) +def _make_scheduler(): + return MagicMock() + + +def _make_metadata_svc(_unused): + return AsyncMock() + + +@contextlib.contextmanager +def _sync_patches(factory, service, scheduler, *, search=False): + async def refresh(session, series_id): + await service.refresh_series(session, series_id) + return ScheduledSeriesRefresh(int(search), search, None, Path("/unused-test-covers")) + + async def daily(session, series_id, *, refresh_days): + await service.fetch_series(session, series_id) + return ScheduledSeriesRefresh(int(search), search, None, Path("/unused-test-covers")) + with ( - _sync_patches(db_factory, svc, scheduler), - patch(f"{_MOD}.get_comicvine_api_key", new_callable=AsyncMock, return_value=None), + patch(f"{_MOD}.get_session_factory", return_value=factory), + patch( + f"{_MOD}.scheduled_series_eligibility", + AsyncMock(return_value=Series.comicvine_id.isnot(None)), + ), + patch(f"{_MOD}.refresh_scheduled_series", side_effect=refresh), + patch(f"{_MOD}.sync_scheduled_issue_catalog", side_effect=daily), + patch(f"{_MOD}.get_scheduler", return_value=scheduler), + patch("pullbox.tasks.metadata_sweep_state.get_scheduler", return_value=scheduler), ): - await getattr(metadata_task, task_id)() - async with db_factory() as session: - state = await load_sweep(session, task_id) - assert state.active is False, "A missing key must not leave a minute-by-minute continuation" - assert state.retry_at == 0 - scheduler.clear_task_continuation.assert_called_once_with(task_id) - scheduler.schedule_task_continuation.assert_not_called() - svc.fetch_series.assert_not_awaited() + yield @pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) @@ -129,897 +134,124 @@ async def test_restore_waits_for_remaining_metadata_batches( await restore_task -async def test_interrupted_sync_restarts_at_first_uncommitted_series(db_factory): - from pullbox.tasks import metadata_task +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_no_eligible_source_stops_an_active_continuation(restore_db_factory, task_id): + async with restore_db_factory.begin() as session: + await save_sweep( + session, task_id, MetadataSweep(cursor=1, upper_bound=3, retry_at=100, active=True) + ) + scheduler = _make_scheduler() + service = _make_metadata_svc([]) + with _sync_patches(restore_db_factory, service, scheduler): + result = await getattr(metadata_task, task_id)() + assert result.status == "completed" + async with restore_db_factory() as session: + state = await load_sweep(session, task_id) + assert not state.active and state.retry_at == 0 + scheduler.clear_task_continuation.assert_called_once_with(task_id) + service.fetch_series.assert_not_awaited() + service.refresh_series.assert_not_awaited() + - ids = [await _create_series(db_factory, comicvine_id=i) for i in (91001, 91002, 91003)] +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_interrupted_sweep_restarts_at_first_uncommitted_series(restore_db_factory, task_id): + factory = restore_db_factory + ids = [await _create_series(factory, comicvine_id=i) for i in (91001, 91002, 91003)] + service = _make_metadata_svc([]) + operation = service.fetch_series if task_id == "sync_new_issues" else service.refresh_series blocked = asyncio.Event() - svc = _make_metadata_svc([]) async def fetch(session, series_id): if series_id == ids[1]: blocked.set() await asyncio.Event().wait() - return [] - svc.fetch_issues_for_series.side_effect = fetch - with _sync_patches(db_factory, svc, _make_scheduler()): - task = asyncio.create_task(metadata_task.sync_new_issues()) - await asyncio.wait_for(blocked.wait(), 2) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - svc.fetch_issues_for_series.side_effect = None - svc.fetch_issues_for_series.return_value = [] - await metadata_task.sync_new_issues() - assert [call.args[1] for call in svc.fetch_series.await_args_list] == [ - 91001, - 91002, - 91002, - 91003, - ] + operation.side_effect = fetch + with _sync_patches(factory, service, _make_scheduler()): + task = asyncio.create_task(getattr(metadata_task, task_id)()) + try: + await asyncio.wait_for(blocked.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + operation.side_effect = None + await getattr(metadata_task, task_id)() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert [call.args[1] for call in operation.await_args_list] == [ids[0], ids[1], ids[1], ids[2]] -async def test_sync_resumes_bounded_batches_from_durable_cursor(db_factory, monkeypatch): - from pullbox.tasks import metadata_task - - for identifier in (91001, 91002, 91003): - await _create_series(db_factory, comicvine_id=identifier) - svc = _make_metadata_svc([]) +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_bounded_sweep_keeps_initial_upper_bound_and_commits_searches( + restore_db_factory, monkeypatch, task_id +): + factory = restore_db_factory + ids = [await _create_series(factory, comicvine_id=i) for i in (91001, 91002, 91003)] + service = _make_metadata_svc([]) + operation = service.fetch_series if task_id == "sync_new_issues" else service.refresh_series scheduler = _make_scheduler() - monkeypatch.setattr(metadata_task, "_METADATA_BATCH_SIZE", 2, raising=False) - with _sync_patches(db_factory, svc, scheduler): - result = await metadata_task.sync_new_issues() - assert svc.fetch_series.await_count == 2, "A run must yield after a bounded batch" - assert result.status == "waiting" - await metadata_task.sync_new_issues() - assert [call.args[1] for call in svc.fetch_series.await_args_list] == [91001, 91002, 91003] - scheduler.schedule_task_continuation.assert_called() - - -async def test_sync_pauses_whole_sweep_after_provider_throttle(db_factory): - from pullbox.core.exceptions import ProviderError - from pullbox.tasks import metadata_task - - for identifier in (91001, 91002, 91003): - await _create_series(db_factory, comicvine_id=identifier) - svc = _make_metadata_svc([]) - svc.fetch_series.side_effect = ProviderError( - "comicvine", "HTTP 420", details={"status_code": 420, "retryable": True} - ) - with _sync_patches(db_factory, svc, _make_scheduler()): - result = await metadata_task.sync_new_issues() - assert svc.fetch_series.await_count == 1, ( - "One throttle must pause the provider, not fail every series" - ) - assert result.status == "waiting" - await metadata_task.sync_new_issues() - assert svc.fetch_series.await_count == 1, "Persisted cooldown must survive a fresh batch" - - -async def test_sync_commits_metadata_before_waiting_for_issue_provider(db_factory): - from pullbox.tasks import metadata_task - - sid = await _create_series(db_factory, comicvine_id=91001) - svc = _make_metadata_svc([]) - tracking = _TrackingFactory(db_factory) - commits_after_metadata = [] - - async def write_series(session, identifier, **kwargs): - series = await session.get(Series, sid) - series.description = "Refreshed metadata" - await session.flush() - commits_after_metadata.append(tracking.commit_calls) - - async def check_issue_fetch(session, series_id): - assert tracking.commit_calls > commits_after_metadata[-1], ( - "Provider wait still holds metadata's write transaction" - ) - return [] - - svc.fetch_series.side_effect = write_series - svc.fetch_issues_for_series.side_effect = check_issue_fetch - with _sync_patches(tracking, svc, _make_scheduler()): - await metadata_task.sync_new_issues() - assert svc.fetch_series.await_args.kwargs.get("download_cover") is False - async with db_factory() as session: - series = await session.get(Series, sid) - assert series.issue_catalog_last_checked_at is not None - - -# ── Fixtures ─────────────────────────────────────────────────────────── - - -@pytest.fixture -async def restore_db_factory(tmp_path): - # Cancellation may discard a connection; persisted restore state must survive it. - engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'restore.db'}") - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield async_sessionmaker(engine, expire_on_commit=False) - await engine.dispose() - - -@pytest.fixture -async def db_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: - engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - factory = async_sessionmaker(engine, expire_on_commit=False) - yield factory - await engine.dispose() - - -async def _create_series( - factory: async_sessionmaker[AsyncSession], - *, - monitored: bool = True, - comicvine_id: int | None = 99900, - issue_count: int = 0, - status: SeriesStatus = SeriesStatus.CONTINUING, - metadata_last_refreshed: datetime | None = None, - issue_catalog_state: IssueCatalogState = IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at: datetime | None = None, - issue_catalog_last_checked_at: datetime | None = None, -) -> int: - """Seed a series, return its ID.""" - async with factory() as session: - series = Series( - comicvine_id=comicvine_id, - title="Batman", - sort_title="Batman", - year_start=2016, - status=status, - series_type=SeriesType.STANDARD, - monitored=monitored, - issue_count=issue_count, - metadata_last_refreshed=metadata_last_refreshed, - issue_catalog_state=issue_catalog_state, - issue_catalog_last_synced_at=issue_catalog_last_synced_at, - issue_catalog_last_checked_at=issue_catalog_last_checked_at, - ) - session.add(series) - await session.commit() - return series.id - - -@contextlib.contextmanager -def _sync_patches( - db_factory: object, - metadata_svc: AsyncMock, - scheduler: MagicMock, -) -> Generator[MagicMock, None, None]: - """Context manager that patches all sync_new_issues dependencies.""" - - async def refresh(session, series_id): - await metadata_svc.refresh_series(session, series_id) - return ScheduledSeriesRefresh(0, False, None, Path("/unused-test-covers")) - - with ( - patch(f"{_MOD}.get_settings") as mock_settings, - patch(f"{_MOD}.get_session_factory", return_value=db_factory), - patch( - f"{_MOD}.get_comicvine_api_key", - new_callable=AsyncMock, - return_value="fake-key", - ), - patch( - f"{_MOD}._create_metadata_service", - return_value=metadata_svc, - ), - patch(f"{_MOD}.get_scheduler", return_value=scheduler), - patch( - f"{_MOD}.scheduled_series_eligibility", - AsyncMock(return_value=Series.comicvine_id.isnot(None)), - ), - patch(f"{_MOD}.refresh_scheduled_series", side_effect=refresh), - patch("pullbox.tasks.metadata_sweep_state.get_scheduler", return_value=scheduler), - ): - mock_settings.return_value = MagicMock() - yield mock_settings - - -def _make_metadata_svc( - issue_ids: list[int], -) -> AsyncMock: - """Create a mock MetadataService with fetch_issues_for_series.""" - svc = AsyncMock() - svc.fetch_series = AsyncMock() - - async def _mock_fetch(session: AsyncSession, sid: int) -> list[Issue]: - from sqlalchemy import select - - result = await session.execute(select(Issue).where(Issue.id.in_(issue_ids))) - return list(result.scalars().all()) - - svc.fetch_issues_for_series.side_effect = _mock_fetch - return svc - - -def _make_scheduler() -> MagicMock: - """Create a mock scheduler with _scheduler.add_job.""" - scheduler = MagicMock() - scheduler._scheduler = MagicMock() - scheduler._scheduler.add_job = MagicMock() - return scheduler - - -class _TrackingFactory: - """Wrap an async session factory and count commit/rollback calls.""" - - def __init__(self, factory: async_sessionmaker[AsyncSession]) -> None: - self._factory = factory - self.commit_calls = 0 - self.rollback_calls = 0 - - def __call__(self, *args: object, **kwargs: object): # type: ignore[no-untyped-def] - real_cm = self._factory(*args, **kwargs) - tracker = self - - class _Wrapper: - async def __aenter__(self) -> AsyncSession: - session = await real_cm.__aenter__() - original_commit = session.commit - original_rollback = session.rollback - - async def tracked_commit() -> None: - tracker.commit_calls += 1 - await original_commit() - - async def tracked_rollback() -> None: - tracker.rollback_calls += 1 - await original_rollback() - - session.commit = tracked_commit # type: ignore[method-assign] - session.rollback = tracked_rollback # type: ignore[method-assign] - return session - - async def __aexit__(self, exc_type, exc, tb) -> bool | None: # type: ignore[no-untyped-def] - await real_cm.__aexit__(exc_type, exc, tb) - return None - - return _Wrapper() - - -# ── Tests ────────────────────────────────────────────────────────────── - + monkeypatch.setattr(metadata_task, "_METADATA_BATCH_SIZE", 2) + with _sync_patches(factory, service, scheduler, search=True): + assert (await getattr(metadata_task, task_id)()).status == "waiting" + assert operation.await_count == 2 + async with factory() as session: + state = await load_sweep(session, task_id) + assert state.cursor == ids[1] and state.upper_bound == ids[-1] + later = await _create_series(factory, comicvine_id=91004) + assert (await getattr(metadata_task, task_id)()).status == "completed" + assert [call.args[1] for call in operation.await_args_list] == ids + assert later not in ids + assert [call.kwargs["args"][0] for call in scheduler._scheduler.add_job.call_args_list] == ids -@pytest.mark.asyncio -async def test_new_wanted_issues_trigger_search( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """When sync finds new issues AND series is monitored, search is scheduled.""" - series_id = await _create_series(db_factory, monitored=True) - async with db_factory() as session: - new_issues = [ - Issue( - series_id=series_id, - comicvine_id=50001, - issue_number=1.0, - title="Issue #1", - status=IssueStatus.SKIPPED, - ), - Issue( - series_id=series_id, - comicvine_id=50002, - issue_number=2.0, - title="Issue #2", - status=IssueStatus.SKIPPED, +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_provider_retry_retains_cursor_and_survives_restart(restore_db_factory, task_id): + factory = restore_db_factory + await _create_series(factory, comicvine_id=91001) + service = _make_metadata_svc([]) + operation = service.fetch_series if task_id == "sync_new_issues" else service.refresh_series + operation.side_effect = SeriesRefreshError( + "Source unavailable", + outcomes=( + SourceOutcome( + source=MetadataSource.METRON_API, + status=SourceStatus.RATE_LIMITED, + retry_after_seconds=720, ), - ] - for issue in new_issues: - session.add(issue) - await session.commit() - issue_ids = [i.id for i in new_issues] - - mock_metadata_svc = _make_metadata_svc(issue_ids) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_scheduler._scheduler.add_job.assert_called_once() - call_kwargs = mock_scheduler._scheduler.add_job.call_args - assert call_kwargs[1]["trigger"] == "date" - assert "search_new_" in call_kwargs[1]["id"] - - -@pytest.mark.asyncio -async def test_no_trigger_if_series_not_monitored( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """No search triggered for unmonitored series.""" - series_id = await _create_series(db_factory, monitored=False) - - async with db_factory() as session: - issue = Issue( - series_id=series_id, - comicvine_id=50001, - issue_number=1.0, - title="Issue #1", - status=IssueStatus.SKIPPED, - ) - session.add(issue) - await session.commit() - issue_ids = [issue.id] - - mock_metadata_svc = _make_metadata_svc(issue_ids) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_scheduler._scheduler.add_job.assert_not_called() - - -@pytest.mark.asyncio -async def test_no_trigger_if_no_new_wanted( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """No search triggered when series is unmonitored and new issues stay SKIPPED.""" - series_id = await _create_series(db_factory, monitored=False) - - async with db_factory() as session: - issue = Issue( - series_id=series_id, - comicvine_id=50001, - issue_number=1.0, - title="Issue #1", - status=IssueStatus.SKIPPED, - ) - session.add(issue) - await session.commit() - issue_ids = [issue.id] - - mock_metadata_svc = _make_metadata_svc(issue_ids) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_scheduler._scheduler.add_job.assert_not_called() - - -@pytest.mark.asyncio -async def test_new_issues_get_monitoring_applied( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """New issues have monitoring criteria applied (SKIPPED → WANTED).""" - series_id = await _create_series(db_factory, monitored=True) - - async with db_factory() as session: - issue = Issue( - series_id=series_id, - comicvine_id=50001, - issue_number=1.0, - title="Issue #1", - status=IssueStatus.SKIPPED, - ) - session.add(issue) - await session.commit() - issue_id = issue.id - - mock_metadata_svc = _make_metadata_svc([issue_id]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - async with db_factory() as session: - loaded_issue = await session.get(Issue, issue_id) - assert loaded_issue is not None - assert loaded_issue.status == IssueStatus.WANTED - - -@pytest.mark.asyncio -async def test_sync_new_issues_commits_per_series( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """sync_new_issues should release the write lock after each series.""" - first_id = await _create_series(db_factory, monitored=True, comicvine_id=99001) - second_id = await _create_series(db_factory, monitored=True, comicvine_id=99002) - - tracking_factory = _TrackingFactory(db_factory) - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(tracking_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - assert mock_metadata_svc.fetch_issues_for_series.await_count == 2 - called_ids = { - call.args[1] for call in mock_metadata_svc.fetch_issues_for_series.await_args_list - } - assert called_ids == {first_id, second_id} - assert tracking_factory.commit_calls >= 2 - - -@pytest.mark.asyncio -async def test_sync_new_issues_skips_fresh_series_metadata_refresh_but_syncs_issues( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Fresh series metadata should not block normal issue-list sync.""" - fresh_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99001, - metadata_last_refreshed=datetime.now(UTC), - ) - stale_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99002, - metadata_last_refreshed=datetime.now(UTC) - timedelta(days=60), - ) - missing_refresh_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99003, - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - refreshed_ids = {call.args[1] for call in mock_metadata_svc.fetch_series.await_args_list} - issue_synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_issues_for_series.await_args_list - } - assert refreshed_ids == {99002, 99003} - assert issue_synced_ids == {fresh_id, stale_id, missing_refresh_id} - - -@pytest.mark.asyncio -async def test_sync_new_issues_uses_recent_sync_for_fresh_complete_catalogs( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """A recently full-synced complete catalog should avoid a full issue-list fetch.""" - full_synced_at = datetime.now(UTC) - timedelta(days=5) - recent_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99101, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=full_synced_at, - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_recent_issues_for_series.await_args_list - } - assert synced_ids == {recent_id} - mock_metadata_svc.fetch_issues_for_series.assert_not_awaited() - - async with db_factory() as session: - series = await session.get(Series, recent_id) - assert series is not None - assert series.issue_catalog_last_synced_at == full_synced_at - assert series.issue_catalog_last_checked_at is not None - - -@pytest.mark.asyncio -async def test_sync_new_issues_skips_continuing_series_checked_within_24_hours( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Continuing catalogs should use the standard 24h issue-check cadence.""" - now = datetime.now(UTC) - await _create_series( - db_factory, - monitored=False, - comicvine_id=99102, - status=SeriesStatus.CONTINUING, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(hours=23), - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_metadata_svc.fetch_issues_for_series.assert_not_awaited() - mock_metadata_svc.fetch_recent_issues_for_series.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sync_new_issues_checks_unknown_series_after_24_hours( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Unknown status uses the same standard cadence as continuing series.""" - now = datetime.now(UTC) - unknown_id = await _create_series( - db_factory, - monitored=False, - comicvine_id=99103, - status=SeriesStatus.UNKNOWN, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(hours=25), - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_recent_issues_for_series.await_args_list - } - assert synced_ids == {unknown_id} - - -@pytest.mark.asyncio -async def test_sync_new_issues_checks_ended_monitored_series_every_14_days( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Ended monitored catalogs use the two-week issue-check cadence.""" - now = datetime.now(UTC) - not_due_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99104, - status=SeriesStatus.ENDED, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(days=13), - ) - due_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99105, - status=SeriesStatus.ENDED, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(days=14, minutes=1), - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_recent_issues_for_series.await_args_list - } - assert synced_ids == {due_id} - assert not_due_id not in synced_ids - - -@pytest.mark.asyncio -async def test_sync_new_issues_ended_unmonitored_takes_30_day_precedence( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Ended status wins over unmonitored, so ended+unmonitored checks every 30 days.""" - now = datetime.now(UTC) - not_due_id = await _create_series( - db_factory, - monitored=False, - comicvine_id=99106, - status=SeriesStatus.ENDED, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(days=29), - ) - due_id = await _create_series( - db_factory, - monitored=False, - comicvine_id=99107, - status=SeriesStatus.ENDED, - metadata_last_refreshed=now, - issue_catalog_last_synced_at=now - timedelta(days=5), - issue_catalog_last_checked_at=now - timedelta(days=30, minutes=1), - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_recent_issues_for_series.await_args_list - } - assert synced_ids == {due_id} - assert not_due_id not in synced_ids - - -@pytest.mark.asyncio -async def test_sync_new_issues_retries_incomplete_catalogs_regardless_of_checked_cadence( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Partial/hydrating/failed catalogs are retry states and bypass normal cadence.""" - now = datetime.now(UTC) - retry_ids = { - await _create_series( - db_factory, - monitored=False, - comicvine_id=99108, - metadata_last_refreshed=now, - issue_catalog_state=IssueCatalogState.PARTIAL, - issue_catalog_last_synced_at=now, - issue_catalog_last_checked_at=now, - ), - await _create_series( - db_factory, - monitored=False, - comicvine_id=99109, - metadata_last_refreshed=now, - issue_catalog_state=IssueCatalogState.HYDRATING, - issue_catalog_last_synced_at=now, - issue_catalog_last_checked_at=now, ), - await _create_series( - db_factory, - monitored=False, - comicvine_id=99110, - metadata_last_refreshed=now, - issue_catalog_state=IssueCatalogState.FAILED, - issue_catalog_last_synced_at=now, - issue_catalog_last_checked_at=now, - ), - } - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - full_synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_issues_for_series.await_args_list - } - assert full_synced_ids == retry_ids - mock_metadata_svc.fetch_recent_issues_for_series.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sync_new_issues_full_syncs_stale_and_incomplete_catalogs( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Stale or incomplete issue catalogs should still get the full issue-list path.""" - stale_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99111, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=datetime.now(UTC) - timedelta(days=60), - ) - partial_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99112, - issue_catalog_state=IssueCatalogState.PARTIAL, - issue_catalog_last_synced_at=datetime.now(UTC) - timedelta(days=5), - ) - never_synced_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99113, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=None, - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - full_synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_issues_for_series.await_args_list - } - assert full_synced_ids == {stale_id, partial_id, never_synced_id} - mock_metadata_svc.fetch_recent_issues_for_series.assert_not_awaited() - - async with db_factory() as session: - never_synced = await session.get(Series, never_synced_id) - assert never_synced is not None - assert never_synced.issue_catalog_state == IssueCatalogState.COMPLETE - assert never_synced.issue_catalog_last_synced_at is not None - assert never_synced.issue_catalog_last_checked_at is not None - - -@pytest.mark.asyncio -async def test_sync_new_issues_keeps_unmonitored_series_in_issue_sync( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Unmonitored series still participate in catalog sync so future monitoring is warm.""" - unmonitored_id = await _create_series( - db_factory, - monitored=False, - comicvine_id=99121, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=datetime.now(UTC) - timedelta(days=5), - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - synced_ids = { - call.args[1] for call in mock_metadata_svc.fetch_recent_issues_for_series.await_args_list - } - assert synced_ids == {unmonitored_id} - mock_scheduler._scheduler.add_job.assert_not_called() - - -@pytest.mark.asyncio -async def test_sync_new_issues_skips_series_without_comicvine_id( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Local-only series should not be treated as failed ComicVine sync work.""" - await _create_series( - db_factory, - monitored=True, - comicvine_id=None, - ) - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler): - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_metadata_svc.fetch_series.assert_not_awaited() - mock_metadata_svc.fetch_issues_for_series.assert_not_awaited() - mock_metadata_svc.fetch_recent_issues_for_series.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sync_new_issues_bootstraps_complete_null_timestamp_catalog_from_local_count( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Complete legacy catalogs with matching local issue counts can avoid first-run full sync.""" - series_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99131, - issue_count=2, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=None, ) - async with db_factory() as session: - session.add_all( - [ - Issue(series_id=series_id, comicvine_id=9913101, issue_number=1.0), - Issue(series_id=series_id, comicvine_id=9913102, issue_number=2.0), - ] - ) - await session.commit() - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() + scheduler = _make_scheduler() + with _sync_patches(factory, service, scheduler): + assert (await getattr(metadata_task, task_id)()).status == "waiting" + assert (await getattr(metadata_task, task_id)()).status == "waiting" + operation.assert_awaited_once() + async with factory() as session: + state = await load_sweep(session, task_id) + assert state.cursor == 0 and state.active and state.retry_at > 0 + scheduler._scheduler.add_job.assert_not_called() - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - await sync_new_issues() +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_structural_failure_rolls_back_one_series_and_continues(restore_db_factory, task_id): + factory = restore_db_factory + ids = [await _create_series(factory, comicvine_id=i) for i in (91001, 91002)] + service = _make_metadata_svc([]) + operation = service.fetch_series if task_id == "sync_new_issues" else service.refresh_series - mock_metadata_svc.fetch_recent_issues_for_series.assert_awaited_once() - mock_metadata_svc.fetch_issues_for_series.assert_not_awaited() - async with db_factory() as session: + async def fetch(session, series_id): series = await session.get(Series, series_id) - assert series is not None - assert series.issue_catalog_last_synced_at is not None - assert series.issue_catalog_last_checked_at is not None + series.description = "New metadata" + if series_id == ids[0]: + await session.flush() + raise SeriesRefreshError("Identity needs review") - -@pytest.mark.asyncio -async def test_sync_new_issues_full_syncs_null_timestamp_catalog_when_local_count_is_short( - db_factory: async_sessionmaker[AsyncSession], -) -> None: - """Legacy complete catalogs still full-sync when local issue rows do not match issue_count.""" - series_id = await _create_series( - db_factory, - monitored=True, - comicvine_id=99132, - issue_count=2, - issue_catalog_state=IssueCatalogState.COMPLETE, - issue_catalog_last_synced_at=None, - ) - async with db_factory() as session: - session.add(Issue(series_id=series_id, comicvine_id=9913201, issue_number=1.0)) - await session.commit() - - mock_metadata_svc = AsyncMock() - mock_metadata_svc.fetch_series = AsyncMock() - mock_metadata_svc.fetch_issues_for_series = AsyncMock(return_value=[]) - mock_metadata_svc.fetch_recent_issues_for_series = AsyncMock(return_value=[]) - mock_scheduler = _make_scheduler() - - with _sync_patches(db_factory, mock_metadata_svc, mock_scheduler) as mock_settings: - mock_settings.return_value.metadata_refresh_days = 30 - from pullbox.tasks.metadata_task import sync_new_issues - - await sync_new_issues() - - mock_metadata_svc.fetch_issues_for_series.assert_awaited_once() - mock_metadata_svc.fetch_recent_issues_for_series.assert_not_awaited() + operation.side_effect = fetch + scheduler = _make_scheduler() + with _sync_patches(factory, service, scheduler, search=True): + assert (await getattr(metadata_task, task_id)()).status == "completed" + async with factory() as session: + assert (await session.get(Series, ids[0])).description is None + assert (await session.get(Series, ids[1])).description == "New metadata" + scheduler._scheduler.add_job.assert_called_once() + assert scheduler._scheduler.add_job.call_args.kwargs["args"] == [ids[1]] From 580500620c19307acf4603744dff1262c74aa5fa Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 16:31:32 -0700 Subject: [PATCH 51/97] feat(metadata): isolate durable retries by series and source Keep transient and authentication failures separate from sweep progress, retry only deferred sources, and settle retry state atomically with catalog writes. Preserve cancellation, restart, and stale-attempt protections. Update scheduler expectations to assert persisted retries instead of a blocked shared cursor, and extend the migration-chain guard for the new retry table. --- ...3p4q5r6s789_add_metadata_series_retries.py | 61 ++++ docs/development/ARCHITECTURE_OVERVIEW.md | 17 +- src/pullbox/models/__init__.py | 2 + src/pullbox/models/metadata_series_retry.py | 42 +++ src/pullbox/services/metadata_daily_sync.py | 23 +- .../services/metadata_scheduled_refresh.py | 12 +- .../services/metadata_series_refresh.py | 5 +- src/pullbox/services/metadata_series_retry.py | 220 ++++++++++++ src/pullbox/tasks/metadata_task.py | 160 ++++++--- .../test_daily_issue_sync.py | 17 +- .../test_isolated_metadata_retry.py | 328 ++++++++++++++++++ .../test_scheduled_series_refresh.py | 20 +- tests/integration/test_migrations.py | 3 +- tests/tasks/test_metadata_search_trigger.py | 14 +- tests/tasks/test_scheduler_wrappers.py | 4 +- 15 files changed, 842 insertions(+), 86 deletions(-) create mode 100644 alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py create mode 100644 src/pullbox/models/metadata_series_retry.py create mode 100644 src/pullbox/services/metadata_series_retry.py create mode 100644 tests/integration/metadata_identity/test_isolated_metadata_retry.py diff --git a/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py b/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py new file mode 100644 index 00000000..abd1eedc --- /dev/null +++ b/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py @@ -0,0 +1,61 @@ +"""Separate deferred metadata source work from the sweep cursor.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "v3p4q5r6s789" +down_revision = "u2o3p4q5r678" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "metadata_series_retries", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("task_id", sa.String(30), nullable=False), + sa.Column( + "series_id", + sa.Integer(), + sa.ForeignKey("series.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("source", sa.String(30), nullable=False), + sa.Column("config_key", sa.String(64), nullable=False), + sa.Column("status", sa.String(30), nullable=False), + sa.Column("retry_at", sa.DateTime(timezone=True)), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("task_id", "series_id", "source", name="uq_metadata_series_retry"), + sa.CheckConstraint( + "task_id IN ('sync_new_issues','refresh_metadata')", name="ck_metadata_retry_task" + ), + sa.CheckConstraint( + "source IN ('*','comicvine_local','comicvine_api','metron_api'," + "'gcd_local','gcd_api_v2')", + name="ck_metadata_retry_source", + ), + sa.CheckConstraint("revision > 0", name="ck_metadata_retry_revision"), + sa.CheckConstraint( + "status IN ('rate_limited','timeout','unavailable','authentication_failed')", + name="ck_metadata_retry_status", + ), + sa.CheckConstraint( + "(status = 'authentication_failed' AND retry_at IS NULL) OR " + "(status != 'authentication_failed' AND retry_at IS NOT NULL)", + name="ck_metadata_retry_deadline", + ), + ) + op.create_index( + "ix_metadata_retry_due", "metadata_series_retries", ["task_id", "retry_at", "series_id"] + ) + + +def downgrade() -> None: + op.drop_table("metadata_series_retries") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index cc8e0d4d..9cc30916 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -680,8 +680,9 @@ Nightly issue and metadata sweeps checkpoint their last completed series and initial upper bound in `SystemConfig`. Each batch handles at most 25 series and checks a two-minute budget between series; an individual series has a separate 15-minute timeout. Pending batches resume through hidden continuations, including -after restart. Provider throttling pauses the sweep at its saved position instead -of repeatedly failing every remaining series. Provider reads happen before the +after restart. Deferred provider work lives in `metadata_series_retries`, keyed +by task, series and source, rather than holding the shared sweep cursor. Batches +reserve space for fresh work and due retries. Provider reads happen before the atomic library/progress write; optional cover work follows its commit. The scheduled metadata refresh selects verified identities backed by executable, configured sources, including native Metron and local ComicVine without an API @@ -692,9 +693,15 @@ cover-cache work. Structured source failures preserve durable retry deadlines; disabling the last eligible source clears the continuation. The daily issue sweep uses the same configured-source eligibility, with partial windows where a valid source checkpoint permits them. Native and local sources -do not require a ComicVine API key. Failed sources retain their checkpoints; -if fallback also fails, earlier retryable outcomes retain the sweep's retry -deadline. Independent per-provider retry progress remains future work. +do not require a ComicVine API key. Failed sources retain their checkpoints, +including when a fallback succeeds. Retry attempts restrict reads to deferred +sources and commit retry settlement with library/cursor changes. Authentication +failures have no timed retry: a changed source policy or credential scope admits +them again. Only one-way configuration scope keys are stored, never credentials. +Cancellation, failed commits and stale attempts cannot discard or resurrect +settled retry work. Infrastructure lock failures retain a sweep-level pause. +Durable account-wide cooldown admission across different series remains separate +from these entity retries; existing provider clients retain process-local throttling. Post-restore aftercare keeps its recovery marker while continuation batches remain active; it observes completion without retaining a database transaction between checks. Cancellation leaves the marker and sweep checkpoint available for the next startup. diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index e1fd14ea..eeedb4a2 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -73,6 +73,7 @@ SeriesIdentityEvent, StoryArcIdentityEvent, ) +from pullbox.models.metadata_series_retry import MetadataSeriesRetry from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.operation_progress import ( OperationProgress, @@ -201,6 +202,7 @@ "MatchConfidence", "MatchingSuggestion", "MetadataProviderCacheEntry", + "MetadataSeriesRetry", "MetadataSourceConfig", "OperationProgress", "OperationProgressState", diff --git a/src/pullbox/models/metadata_series_retry.py b/src/pullbox/models/metadata_series_retry.py new file mode 100644 index 00000000..562959d2 --- /dev/null +++ b/src/pullbox/models/metadata_series_retry.py @@ -0,0 +1,42 @@ +"""Deferred scheduled metadata work, separate from successful catalog progress.""" + +from datetime import datetime + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime + + +class MetadataSeriesRetry(Base, IdentityMixin, TimestampMixin): + __tablename__ = "metadata_series_retries" + __table_args__ = ( + UniqueConstraint("task_id", "series_id", "source", name="uq_metadata_series_retry"), + CheckConstraint( + "task_id IN ('sync_new_issues','refresh_metadata')", name="ck_metadata_retry_task" + ), + CheckConstraint( + "source IN ('*','comicvine_local','comicvine_api','metron_api'," + "'gcd_local','gcd_api_v2')", + name="ck_metadata_retry_source", + ), + CheckConstraint("revision > 0", name="ck_metadata_retry_revision"), + CheckConstraint( + "status IN ('rate_limited','timeout','unavailable','authentication_failed')", + name="ck_metadata_retry_status", + ), + CheckConstraint( + "(status = 'authentication_failed' AND retry_at IS NULL) OR " + "(status != 'authentication_failed' AND retry_at IS NOT NULL)", + name="ck_metadata_retry_deadline", + ), + Index("ix_metadata_retry_due", "task_id", "retry_at", "series_id"), + ) + + task_id: Mapped[str] = mapped_column(String(30), nullable=False) + series_id: Mapped[int] = mapped_column(ForeignKey("series.id", ondelete="CASCADE")) + source: Mapped[str] = mapped_column(String(30), nullable=False) + config_key: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(30), nullable=False) + retry_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) diff --git a/src/pullbox/services/metadata_daily_sync.py b/src/pullbox/services/metadata_daily_sync.py index 8534e63e..873b4c9c 100644 --- a/src/pullbox/services/metadata_daily_sync.py +++ b/src/pullbox/services/metadata_daily_sync.py @@ -100,14 +100,18 @@ def _window_requires_full( async def sync_scheduled_issue_catalog( - session: AsyncSession, series_id: int, *, refresh_days: int + session: AsyncSession, + series_id: int, + *, + refresh_days: int, + registry: MetadataSourceRegistry | None = None, ) -> ScheduledSeriesRefresh: """Read outside transactions, then save issues and progress for the task to commit.""" if session.new or session.dirty or session.deleted: raise SeriesRefreshError("Finish pending library changes before synchronizing issues.") try: async with asyncio.timeout(120): - return await _sync(session, series_id, refresh_days=refresh_days) + return await _sync(session, series_id, refresh_days=refresh_days, registry=registry) except SeriesRefreshError: raise except (ValueError, IntegrityError, ValidationError) as exc: @@ -121,7 +125,11 @@ async def sync_scheduled_issue_catalog( async def _sync( - session: AsyncSession, series_id: int, *, refresh_days: int + session: AsyncSession, + series_id: int, + *, + refresh_days: int, + registry: MetadataSourceRegistry | None = None, ) -> ScheduledSeriesRefresh: before = await read_series_refresh_state(session, series_id) series = await session.get(Series, series_id) @@ -129,7 +137,7 @@ async def _sync( cadence = _cadence(series) covers = await resolve_covers_dir(session) enabled = get_settings().metadata_gcd_api_v2_enabled - registry = MetadataSourceRegistry( + registry = registry or MetadataSourceRegistry( await load_source_runtime(session, gcd_api_enabled=enabled), gcd_api_enabled=enabled, revalidate_reads=True, @@ -161,12 +169,12 @@ async def _sync( if before.series.values.status == SeriesStatus.ENDED.value else 1 ) - failures = [] + failures: list[SourceOutcome] = [] for source in sources: checkpoint = checkpoints[source] full = _full_due(cadence, checkpoint, now, refresh_days) if not full and checkpoint is not None and now - checkpoint.checked_at < interval: - return ScheduledSeriesRefresh(0, False, None, covers) + return ScheduledSeriesRefresh(0, False, None, covers, tuple(failures)) full = ( full or registry.source_availability(source, SourceCapability.RECENT_ISSUES) is not None ) @@ -198,7 +206,7 @@ async def _sync( session, before, cadence, checkpoint, fetched.data, started_at ) return ScheduledSeriesRefresh( - len(created), cadence.monitored and bool(created), None, covers + len(created), cadence.monitored and bool(created), None, covers, tuple(failures) ) if full: try: @@ -219,6 +227,7 @@ async def _sync( result.series.monitored and bool(result.created_issue_ids), result.series.cover_url, covers, + (*failures, *result.outcomes), ) raise SeriesRefreshError( "No configured source could supply the issue catalog. Check source status and retry.", diff --git a/src/pullbox/services/metadata_scheduled_refresh.py b/src/pullbox/services/metadata_scheduled_refresh.py index 5fb9271b..9d0cfac4 100644 --- a/src/pullbox/services/metadata_scheduled_refresh.py +++ b/src/pullbox/services/metadata_scheduled_refresh.py @@ -12,7 +12,7 @@ from pullbox.models import Issue, Series from pullbox.models.issue import IssueStatus from pullbox.models.metadata_identity import SeriesExternalIdentity -from pullbox.schemas.metadata_sources import SourceCapability +from pullbox.schemas.metadata_sources import SourceCapability, SourceOutcome from pullbox.services.cover_resolver import resolve_covers_dir from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_series_refresh import refresh_series_catalog_from_sources @@ -50,12 +50,17 @@ class ScheduledSeriesRefresh: search_wanted: bool cover_url: str | None covers: Path + outcomes: tuple[SourceOutcome, ...] = () -async def refresh_scheduled_series(session: AsyncSession, series_id: int) -> ScheduledSeriesRefresh: +async def refresh_scheduled_series( + session: AsyncSession, series_id: int, *, registry: MetadataSourceRegistry | None = None +) -> ScheduledSeriesRefresh: """Fill gaps and synchronize a complete catalog; the task commits progress too.""" covers = await resolve_covers_dir(session) - result = await refresh_series_catalog_from_sources(session, series_id, replace_managed=False) + result = await refresh_series_catalog_from_sources( + session, series_id, registry=registry, replace_managed=False + ) wanted = ( await session.scalar( select(Issue.id) @@ -74,4 +79,5 @@ async def refresh_scheduled_series(session: AsyncSession, series_id: int) -> Sch result.series.monitored and wanted is not None, result.series.cover_url, covers, + result.outcomes, ) diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index 3aeb6b42..31886793 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path @@ -76,6 +76,7 @@ def __init__( class SeriesCatalogRefresh: series: Series created_issue_ids: tuple[int, ...] + outcomes: tuple[SourceOutcome, ...] = () @asynccontextmanager @@ -278,7 +279,7 @@ async def refresh_series_catalog_from_sources( source=bundle.series.source.value, catalog_count=bundle.catalog_total, ) - return result + return replace(result, outcomes=tuple(failed.values())) except SeriesRefreshError: raise except IssueCatalogConflictError as exc: diff --git a/src/pullbox/services/metadata_series_retry.py b/src/pullbox/services/metadata_series_retry.py new file mode 100644 index 00000000..85d8002d --- /dev/null +++ b/src/pullbox/services/metadata_series_retry.py @@ -0,0 +1,220 @@ +"""Bounded, caller-committed admission and retries for scheduled source reads.""" + +import hashlib +import math +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from pullbox.models import MetadataSeriesRetry, Series +from pullbox.models.series import IssueCatalogState +from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_sources import SourceRuntime, load_source_runtime +from pullbox.services.metadata_writer_identity import metadata_write_scope + +TRANSIENT = {SourceStatus.RATE_LIMITED, SourceStatus.TIMEOUT, SourceStatus.UNAVAILABLE} + + +def config_key(runtime: SourceRuntime) -> str: + # Only a one-way scope key is retained; credentials never enter retry records. + token = runtime.credential.get_secret_value() if runtime.credential else "" + return hashlib.sha256(f"{runtime.policy.revision}:{token}".encode()).hexdigest() + + +@dataclass(frozen=True) +class RetryToken: + id: int + source: str + revision: int + + +@dataclass(frozen=True) +class RetryAdmission: + registry: MetadataSourceRegistry + config_keys: dict[str, str] + tokens: tuple[RetryToken, ...] + held: frozenset[str] + + +async def retry_runtime(session: AsyncSession, *, gcd_api_enabled: bool) -> list[SourceRuntime]: + return await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled) + + +def _changed(runtime: list[SourceRuntime]) -> ColumnElement[bool]: + return or_( + False, + *( + (MetadataSeriesRetry.source == item.policy.source.value) + & (MetadataSeriesRetry.config_key != config_key(item)) + for item in runtime + if item.policy.enabled and item.unavailable is None + ), + ) + + +async def retry_candidates( + session: AsyncSession, task_id: str, runtime: list[SourceRuntime], *, limit: int +) -> list[int]: + return list( + await session.scalars( + select(MetadataSeriesRetry.series_id) + .join(Series, Series.id == MetadataSeriesRetry.series_id) + .where( + MetadataSeriesRetry.task_id == task_id, + Series.issue_catalog_state != IssueCatalogState.HYDRATING, + or_(MetadataSeriesRetry.retry_at <= datetime.now(UTC), _changed(runtime)), + ) + .group_by(MetadataSeriesRetry.series_id) + .order_by(func.min(MetadataSeriesRetry.retry_at), MetadataSeriesRetry.series_id) + .limit(limit) + ) + ) + + +async def retry_deadline( + session: AsyncSession, task_id: str, runtime: list[SourceRuntime] +) -> datetime | None: + if await session.scalar( + select(MetadataSeriesRetry.id) + .where(MetadataSeriesRetry.task_id == task_id, _changed(runtime)) + .limit(1) + ): + return datetime.now(UTC) + return await session.scalar( + select(func.min(MetadataSeriesRetry.retry_at)).where(MetadataSeriesRetry.task_id == task_id) + ) + + +async def prune_retries(session: AsyncSession, task_id: str, eligible: ColumnElement[bool]) -> None: + await session.execute( + delete(MetadataSeriesRetry).where( + MetadataSeriesRetry.task_id == task_id, + MetadataSeriesRetry.series_id.not_in(select(Series.id).where(eligible)), + ) + ) + + +async def admit_retry( + session: AsyncSession, + task_id: str, + series_id: int, + *, + gcd_api_enabled: bool, + retry_only: bool, +) -> RetryAdmission: + runtime = await retry_runtime(session, gcd_api_enabled=gcd_api_enabled) + keys = {item.policy.source.value: config_key(item) for item in runtime} + rows = list( + await session.scalars( + select(MetadataSeriesRetry).where( + MetadataSeriesRetry.task_id == task_id, MetadataSeriesRetry.series_id == series_id + ) + ) + ) + now = datetime.now(UTC) + held = frozenset( + row.source + for row in rows + if row.source != "*" + and row.config_key == keys.get(row.source) + and (row.retry_at is None or row.retry_at > now) + ) + due = {row.source for row in rows if row.source not in held} + selected = [ + item + for item in runtime + if item.policy.source.value not in held + and (not retry_only or "*" in due or item.policy.source.value in due) + ] + return RetryAdmission( + MetadataSourceRegistry( + selected, gcd_api_enabled=gcd_api_enabled, revalidate_reads=True, total_timeout=60 + ), + keys, + tuple(RetryToken(row.id, row.source, row.revision) for row in rows), + held, + ) + + +async def settle_retry( + session: AsyncSession, + task_id: str, + series_id: int, + admission: RetryAdmission, + *, + outcomes: tuple[SourceOutcome, ...] = (), + retry_seconds: float | None = None, +) -> None: + """Settle only the read generation; newer deferred work must survive this caller.""" + failures = { + item.source.value: item + for item in outcomes + if item.status in TRANSIENT | {SourceStatus.AUTHENTICATION_FAILED} + } + now = datetime.now(UTC) + pending: dict[str, tuple[str, datetime | None, str]] = {} + for source, item in failures.items(): + if source in admission.held: + continue + delay = item.retry_after_seconds or ( + 3600 if item.status is SourceStatus.RATE_LIMITED else 300 + ) + pending[source] = ( + item.status.value, + None + if item.status is SourceStatus.AUTHENTICATION_FAILED + else now + timedelta(seconds=max(60, min(delay, 7 * 86400))), + admission.config_keys[source], + ) + if retry_seconds is not None and not failures: + if not math.isfinite(retry_seconds) or retry_seconds < 0: + raise ValueError("Invalid metadata retry deadline") + pending["*"] = ( + SourceStatus.TIMEOUT.value, + now + timedelta(seconds=max(60, min(retry_seconds, 7 * 86400))), + "", + ) + async with metadata_write_scope(session): + if ( + await session.scalar(select(Series.id).where(Series.id == series_id).with_for_update()) + is None + ): + return + tokens = {item.source: item for item in admission.tokens} + rows = { + row.source: row + for row in await session.scalars( + select(MetadataSeriesRetry) + .where( + MetadataSeriesRetry.task_id == task_id, + MetadataSeriesRetry.series_id == series_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + } + for source in (set(tokens) | set(pending)) - admission.held: + row, token = rows.get(source), tokens.get(source) + if token is not None and row is None: + continue + if row is not None and ( + token is None or (row.id, row.revision) != (token.id, token.revision) + ): + continue + if source not in pending: + if row is not None: + await session.delete(row) + continue + status, due, key = pending[source] + if row is None: + row = MetadataSeriesRetry( + task_id=task_id, series_id=series_id, source=source, revision=0 + ) + session.add(row) + row.status, row.retry_at, row.config_key = status, due, key + row.revision += 1 + await session.flush() diff --git a/src/pullbox/tasks/metadata_task.py b/src/pullbox/tasks/metadata_task.py index 03c98c85..f2a336ec 100644 --- a/src/pullbox/tasks/metadata_task.py +++ b/src/pullbox/tasks/metadata_task.py @@ -24,6 +24,14 @@ scheduled_series_eligibility, ) from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_artwork +from pullbox.services.metadata_series_retry import ( + admit_retry, + prune_retries, + retry_candidates, + retry_deadline, + retry_runtime, + settle_retry, +) from pullbox.tasks.metadata_sweep_state import save_sweep, schedule_sweep, start_sweep logger = structlog.get_logger(__name__) @@ -52,18 +60,12 @@ def _provider_pause_seconds(exc: Exception) -> float | None: max( 60, outcome.retry_after_seconds - or ( - 3600 - if outcome.status - in {SourceStatus.RATE_LIMITED, SourceStatus.AUTHENTICATION_FAILED} - else 300 - ), + or (3600 if outcome.status is SourceStatus.RATE_LIMITED else 300), ) for outcome in exc.outcomes if outcome.status in { SourceStatus.RATE_LIMITED, - SourceStatus.AUTHENTICATION_FAILED, SourceStatus.TIMEOUT, SourceStatus.UNAVAILABLE, } @@ -92,22 +94,29 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: ) state = await start_sweep(session, task_id, eligible=eligible) refresh_days = _metadata_refresh_days(settings) + base = [eligible] + if task_id == "refresh_metadata": + base.append(Series.monitored.is_(True)) + await prune_retries( + session, + task_id, + base[0] + & (Series.monitored.is_(True) if task_id == "refresh_metadata" else Series.id > 0), + ) + runtime = await retry_runtime(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) predicates = [ - eligible, + *base, Series.id > state.cursor, Series.id <= state.upper_bound, Series.issue_catalog_state != IssueCatalogState.HYDRATING, ] if task_id == "refresh_metadata": - predicates.extend( - [ - Series.monitored.is_(True), - or_( - Series.metadata_last_refreshed.is_(None), - Series.metadata_last_refreshed - < datetime.now(UTC) - timedelta(days=refresh_days), - ), - ] + predicates.append( + or_( + Series.metadata_last_refreshed.is_(None), + Series.metadata_last_refreshed + < datetime.now(UTC) - timedelta(days=refresh_days), + ) ) ids = list( await session.scalars( @@ -117,55 +126,88 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: .limit(_METADATA_BATCH_SIZE + 1) ) ) - if not ids: - state.active = False - state.retry_at = 0 + pending = await retry_candidates(session, task_id, runtime, limit=_METADATA_BATCH_SIZE) + due = ( + list( + await session.scalars( + select(Series.id) + .where( + *base, + Series.id.in_(pending), + Series.issue_catalog_state != IssueCatalogState.HYDRATING, + ) + .order_by(Series.id) + ) + ) + if pending + else [] + ) + if not ids and not due: + deadline = await retry_deadline(session, task_id, runtime) + state.active = deadline is not None + state.retry_at = deadline.timestamp() if deadline else 0 await save_sweep(session, task_id, state) await session.commit() schedule_sweep(task_id, state) - return TaskExecutionResult(status="completed") + return TaskExecutionResult(status="waiting" if state.active else "completed") schedule_sweep(task_id, state) - if state.retry_at > datetime.now(UTC).timestamp(): + if state.retry_at > datetime.now(UTC).timestamp() and not due: + await session.commit() return TaskExecutionResult(status="waiting") await session.commit() - processed = failed = new_issues = 0 + # Reserve space for both fresh work and overdue retries, avoiding starvation. + quota = max(1, _METADATA_BATCH_SIZE // 2) if due else _METADATA_BATCH_SIZE + work = [(sid, False) for sid in ids[:quota]] + selected = {sid for sid, _ in work} + work.extend((sid, True) for sid in due if sid not in selected) + processed = normal_done = failed = new_issues = 0 paused = False - for series_id in ids[:_METADATA_BATCH_SIZE]: + for series_id, retry_only in work[:_METADATA_BATCH_SIZE]: if processed and time.monotonic() - started >= _METADATA_BATCH_SECONDS: break - series = await session.get(Series, series_id) - if series is None: - state.cursor = series_id - await save_sweep(session, task_id, state) - await session.commit() - processed += 1 - continue previous_cursor = state.cursor + admission = await admit_retry( + session, + task_id, + series_id, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + retry_only=retry_only, + ) refreshed: ScheduledSeriesRefresh | None = None + committing = False try: async with asyncio.timeout(_METADATA_SERIES_SECONDS): refreshed = ( - await refresh_scheduled_series(session, series_id) + await refresh_scheduled_series( + session, series_id, registry=admission.registry + ) if task_id == "refresh_metadata" else await sync_scheduled_issue_catalog( - session, series_id, refresh_days=refresh_days + session, + series_id, + refresh_days=refresh_days, + registry=admission.registry, ) ) - state.cursor = series_id + committing = True + await settle_retry( + session, task_id, series_id, admission, outcomes=refreshed.outcomes + ) + if not retry_only: + state.cursor = series_id state.retry_at = 0 await save_sweep(session, task_id, state) await session.commit() - processed += 1 except Exception as exc: refreshed = None await session.rollback() state.cursor = previous_cursor - pause_seconds = _provider_pause_seconds(exc) - if pause_seconds is not None: - state.retry_at = ( - datetime.now(UTC) + timedelta(seconds=pause_seconds) - ).timestamp() + if committing: + # A failed atomic write must leave the previous cursor/retry intact. + raise + if is_sqlite_locked_error(exc): + state.retry_at = (datetime.now(UTC) + timedelta(seconds=60)).timestamp() await save_sweep(session, task_id, state) await session.commit() paused = True @@ -173,29 +215,39 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: "metadata_sweep_paused", task_id=task_id, series_id=series_id, - retry_seconds=pause_seconds, + retry_seconds=60, failure_type=type(exc).__name__, ) break - failed += 1 - processed += 1 - state.cursor = series_id + outcomes = exc.outcomes if isinstance(exc, SeriesRefreshError) else () + await settle_retry( + session, + task_id, + series_id, + admission, + outcomes=outcomes, + retry_seconds=_provider_pause_seconds(exc) if not outcomes else None, + ) + if not retry_only: + state.cursor = series_id + state.retry_at = 0 await save_sweep(session, task_id, state) await session.commit() + failed += 1 if isinstance(exc, SeriesRefreshError): logger.warning( - f"{task_id}_series_failed", + f"{task_id}_series_deferred" + if _provider_pause_seconds(exc) or outcomes + else f"{task_id}_series_failed", series_id=series_id, reason=str(exc), - source_statuses={ - item.source.value: item.status.value for item in exc.outcomes - }, + source_statuses={item.source.value: item.status.value for item in outcomes}, ) else: logger.exception(f"{task_id}_series_failed", series_id=series_id) - - # Search cannot precede the atomic issue/checkpoint/cursor commit. - # Optional artwork never postpones wanted searches or rewinds progress. + processed += 1 + normal_done += int(not retry_only) + # Issue writes, deferred source work and sweep progress precede search. if refreshed is not None: new_issues += refreshed.added if refreshed.search_wanted: @@ -205,7 +257,11 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult: session, series_id, refreshed.cover_url, refreshed.covers ) - state.active = paused or processed < len(ids) + deadline = await retry_deadline(session, task_id, runtime) + more = normal_done < len(ids) + state.active = paused or more or deadline is not None + if not paused: + state.retry_at = 0 if more or deadline is None else deadline.timestamp() await save_sweep(session, task_id, state) await session.commit() schedule_sweep(task_id, state) diff --git a/tests/integration/metadata_identity/test_daily_issue_sync.py b/tests/integration/metadata_identity/test_daily_issue_sync.py index 8abbf227..0a1be5fc 100644 --- a/tests/integration/metadata_identity/test_daily_issue_sync.py +++ b/tests/integration/metadata_identity/test_daily_issue_sync.py @@ -10,7 +10,7 @@ from pullbox.core.comicvine_key import save_comicvine_api_key from pullbox.core.metadata_identity import MetadataEntityKind as Kind from pullbox.core.metadata_identity import MetadataSource as Source -from pullbox.models import Issue, Series, SeriesCatalogCheckpoint +from pullbox.models import Issue, MetadataSeriesRetry, Series, SeriesCatalogCheckpoint from pullbox.models.issue import IssueStatus from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.series import IssueCatalogState, SeriesStatus @@ -191,12 +191,19 @@ async def test_daily_window_failure_preserves_issue_checkpoint_and_durable_retry offer(adapter, data) adapter.recent_failure = failure result = await metadata_task.sync_new_issues() - assert result.status == "waiting" + auth = failure is SourceStatus.AUTHENTICATION_FAILED + assert result.status == ("completed" if auth else "waiting") async with factory() as session: assert await load_catalog_checkpoint(session, series_id, Source.METRON_API) == old assert (await session.get(Series, series_id)).issue_count == 1 state = await load_sweep(session, "sync_new_issues") - assert state.cursor == 0 and state.retry_at > datetime.now(UTC).timestamp() + assert state.cursor == series_id + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.source == Source.METRON_API.value and row.status == failure.value + if auth: + assert not state.active and row.retry_at is None + else: + assert state.retry_at == row.retry_at.timestamp() > datetime.now(UTC).timestamp() await metadata_task.sync_new_issues() assert len(adapter.calls) == 1 scheduler._scheduler.add_job.assert_not_called() @@ -292,12 +299,14 @@ async def test_daily_source_fallback_preserves_failed_source_progress( factories = {**registry(api).factories, **registry(local).factories} monkeypatch.setattr(sources, "metadata_sources", lambda: factories) result = await metadata_task.sync_new_issues() - assert result.status == ("waiting" if fallback_fails else "completed") + assert result.status == "waiting", "Fallback success must retain deferred source work" assert [call[0] for call in api.calls] == ["recent"] assert [call[0] for call in local.calls] == (["series"] if fallback_fails else ["recent"]) async with factory() as session: assert await load_catalog_checkpoint(session, series_id, Source.COMICVINE_API) == old_api assert (await session.get(Series, series_id)).issue_count == (1 if fallback_fails else 2) + retry = await session.scalar(select(MetadataSeriesRetry)) + assert retry.source == Source.COMICVINE_API.value and retry.retry_at is not None assert scheduler._scheduler.add_job.call_count == int(not fallback_fails) diff --git a/tests/integration/metadata_identity/test_isolated_metadata_retry.py b/tests/integration/metadata_identity/test_isolated_metadata_retry.py new file mode 100644 index 00000000..7174056b --- /dev/null +++ b/tests/integration/metadata_identity/test_isolated_metadata_retry.py @@ -0,0 +1,328 @@ +"""A source outage must not strand unrelated series or lose deferred source work.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import delete, select, update + +from pullbox.core.encryption import encrypt_secret +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Base, MetadataSeriesRetry, Series +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import IssueCatalogState +from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.services.metadata_series_retry import admit_retry, retry_candidates, settle_retry +from pullbox.tasks import metadata_task +from pullbox.tasks.metadata_sweep_state import load_sweep, save_sweep +from tests.integration.metadata_identity.test_daily_issue_sync import ( # noqa: F401 + DailyAdapter, + daily, +) +from tests.integration.metadata_identity.test_scheduled_series_refresh import ( # noqa: F401 + add_series, + configured_sources, + offer, + scheduled, +) +from tests.unit.test_metadata_source_reads import registry + + +@pytest.fixture +async def isolated(daily): # noqa: F811 - imported pytest fixture + return daily + + +async def expire_retry(factory, task_id): + async with factory.begin() as session: + await session.execute( + update(MetadataSeriesRetry) + .where( + MetadataSeriesRetry.task_id == task_id, + MetadataSeriesRetry.retry_at.isnot(None), + ) + .values(retry_at=datetime.now(UTC) - timedelta(seconds=1)) + ) + state = await load_sweep(session, task_id) + state.retry_at = 0 + await save_sweep(session, task_id, state) + + +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_retryable_series_does_not_block_the_next_series(isolated, monkeypatch, task_id): + factory, _, scheduler = isolated + first, first_data = await add_series(factory) + second, second_data = await add_series(factory, identifier=43) + adapter = DailyAdapter(first_data) + offer(adapter, second_data) + original = adapter.series + + async def fetch(identifier, **kwargs): + if identifier == "42": + adapter.calls.append(("failed", identifier)) + raise MetadataSourceError(SourceStatus.TIMEOUT, retry_after_seconds=720) + return await original(identifier, **kwargs) + + adapter.series = fetch + from pullbox.providers.metadata import sources + + monkeypatch.setattr(sources, "metadata_sources", lambda: registry(adapter).factories) + result = await getattr(metadata_task, task_id)() + async with factory() as session: + assert (await session.get(Series, second)).issue_count == 2, ( + "A temporary failure in the first series must not block the second" + ) + assert (await session.get(Series, first)).issue_count == 1 + assert result.status == "waiting" + assert scheduler._scheduler.add_job.call_args.kwargs["args"] == [second] + calls = list(adapter.calls) + assert (await getattr(metadata_task, task_id)()).status == "waiting" + assert adapter.calls == calls + await expire_retry(factory, task_id) + await factory.kw["bind"].dispose() + adapter.series = original + offer(adapter, first_data) + assert (await getattr(metadata_task, task_id)()).status == "completed" + assert all(call[1] == "42" for call in adapter.calls[len(calls) :]) + async with factory() as session: + assert (await session.get(Series, first)).issue_count == 2 + assert list(await session.scalars(select(MetadataSeriesRetry))) == [] + assert scheduler._scheduler.add_job.call_count == 2 + + +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_auth_failure_does_not_schedule_automatic_retry(isolated, monkeypatch, task_id): + factory, adapter, scheduler = isolated + series_id, data = await add_series(factory) + offer(adapter, data) + original = adapter.series + + async def rejected(identifier, **kwargs): + adapter.calls.append(("auth", identifier)) + raise MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED) + + adapter.series = rejected + result = await getattr(metadata_task, task_id)() + assert result.status == "completed", "Bad credentials require configuration, not timed retry" + calls = list(adapter.calls) + await getattr(metadata_task, task_id)() + assert adapter.calls == calls, "Starting another sweep must not repeat known-bad credentials" + scheduler._scheduler.add_job.assert_not_called() + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 1 + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.retry_at is None and row.status == "authentication_failed" + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values(credential_secret=encrypt_secret("corrected-scheduled-token")) + ) + adapter.series = original + assert (await getattr(metadata_task, task_id)()).status == "completed" + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 2 + assert list(await session.scalars(select(MetadataSeriesRetry))) == [] + + +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_successful_fallback_retries_only_the_failed_source(isolated, monkeypatch, task_id): + from pullbox.core.comicvine_key import save_comicvine_api_key + from pullbox.providers.metadata import sources + + factory, _, scheduler = isolated + async with factory.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False)) + for source, rank in [(Source.COMICVINE_API, 0), (Source.COMICVINE_LOCAL, 1)]: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == source.value) + .values(enabled=True, priority=rank) + ) + await save_comicvine_api_key(session, "fallback-test-key") + series_id, local_data = await add_series(factory, source=Source.COMICVINE_LOCAL) + local = DailyAdapter(local_data) + local.source = Source.COMICVINE_LOCAL + offer(local, local_data) + api = DailyAdapter(local_data) + api.source = Source.COMICVINE_API + api.data = replace( + local.data, + series=local.data.series.model_copy(update={"source": Source.COMICVINE_API}), + issues=tuple( + row.model_copy(update={"source": Source.COMICVINE_API}) for row in local.data.issues + ), + ) + original = api.series + + async def throttled(identifier, **kwargs): + api.calls.append(("throttled", identifier)) + raise MetadataSourceError(SourceStatus.RATE_LIMITED, retry_after_seconds=720) + + api.series = throttled + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: {**registry(api).factories, **registry(local).factories}, + ) + assert (await getattr(metadata_task, task_id)()).status == "waiting" + async with factory() as session: + assert (await session.get(Series, series_id)).issue_count == 2 + assert ( + await session.scalar(select(MetadataSeriesRetry)) + ).source == Source.COMICVINE_API.value + local_calls = list(local.calls) + await expire_retry(factory, task_id) + api.series = original + assert (await getattr(metadata_task, task_id)()).status == "completed" + assert local.calls == local_calls, "A retry must not repeat healthy source requests" + async with factory() as session: + assert list(await session.scalars(select(MetadataSeriesRetry))) == [] + scheduler._scheduler.add_job.assert_called_once() + + +async def test_retry_migration_matches_model_without_touching_library(identity_probe_db): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import Column, Integer, MetaData, Table + + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + series = Series(title="Preserved", sort_title="Preserved") + session.add(series) + await session.flush() + series_id = series.id + async with engine.begin() as conn: + + def migrate(sync): + revision = _revision("v3p4q5r6s789_add_metadata_series_retries", sync) + revision.downgrade() + revision.upgrade() + expected = MetaData() + Table("series", expected, Column("id", Integer, primary_key=True)) + name = "metadata_series_retries" + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, item, type_, reflected, compare_to: ( + item == name if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + assert compare_metadata(context, expected) == [] + + await conn.run_sync(migrate) + async with factory() as session: + assert (await session.get(Series, series_id)).title == "Preserved" + assert list(await session.scalars(select(MetadataSeriesRetry))) == [] + + +async def seed_failure(isolated): + factory, adapter, _ = isolated + series_id, data = await add_series(factory) + offer(adapter, data) + original = adapter.series + + async def timeout(*args, **kwargs): + raise MetadataSourceError(SourceStatus.TIMEOUT, retry_after_seconds=720) + + adapter.series = timeout + assert (await metadata_task.refresh_metadata()).status == "waiting" + adapter.series = original + return factory, adapter, series_id + + +async def test_stale_attempt_cannot_restore_a_deleted_retry(isolated): + factory, _, series_id = await seed_failure(isolated) + await expire_retry(factory, "refresh_metadata") + async with factory() as reader: + admission = await admit_retry( + reader, "refresh_metadata", series_id, gcd_api_enabled=False, retry_only=True + ) + async with factory.begin() as writer: + await writer.execute(delete(MetadataSeriesRetry)) + async with factory.begin() as writer: + await settle_retry( + writer, + "refresh_metadata", + series_id, + admission, + outcomes=(SourceOutcome(source=Source.METRON_API, status=SourceStatus.TIMEOUT),), + ) + async with factory() as session: + assert list(await session.scalars(select(MetadataSeriesRetry))) == [], ( + "An obsolete failure cannot recreate work that another attempt settled" + ) + + +async def test_failed_retry_commit_preserves_deferred_work_and_metadata(isolated, monkeypatch): + factory, _, series_id = await seed_failure(isolated) + await expire_retry(factory, "refresh_metadata") + async with factory() as session: + row = await session.scalar(select(MetadataSeriesRetry)) + previous = (row.id, row.revision, row.retry_at) + original = metadata_task.save_sweep + saves = 0 + + async def fail(session, task_id, state): + nonlocal saves + saves += 1 + if saves == 1: + raise RuntimeError("transaction failed") + await original(session, task_id, state) + + monkeypatch.setattr(metadata_task, "save_sweep", fail) + with pytest.raises(RuntimeError, match="transaction failed"): + await metadata_task.refresh_metadata() + async with factory() as session: + row = await session.scalar(select(MetadataSeriesRetry)) + assert (row.id, row.revision, row.retry_at) == previous + assert (await session.get(Series, series_id)).issue_count == 1 + + +async def test_cancelled_retry_is_retained_for_resume(isolated): + factory, adapter, series_id = await seed_failure(isolated) + await expire_retry(factory, "refresh_metadata") + adapter.started.clear() + adapter.wait = asyncio.Event() + task = asyncio.create_task(metadata_task.refresh_metadata()) + try: + await asyncio.wait_for(adapter.started.wait(), 3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + assert await session.scalar(select(MetadataSeriesRetry.id)) + assert (await session.get(Series, series_id)).issue_count == 1 + adapter.wait = None + assert (await metadata_task.refresh_metadata()).status == "completed" + + +async def test_hydrating_retry_does_not_hide_ready_work_behind_page_limit(isolated): + factory, _, _ = isolated + first, _ = await add_series(factory) + second, _ = await add_series(factory, identifier=43) + async with factory.begin() as session: + (await session.get(Series, first)).issue_catalog_state = IssueCatalogState.HYDRATING + for identifier in (first, second): + session.add( + MetadataSeriesRetry( + task_id="refresh_metadata", + series_id=identifier, + source="*", + config_key="", + status="timeout", + retry_at=datetime.now(UTC) - timedelta(hours=1), + ) + ) + async with factory() as session: + assert await retry_candidates(session, "refresh_metadata", [], limit=1) == [second] diff --git a/tests/integration/metadata_identity/test_scheduled_series_refresh.py b/tests/integration/metadata_identity/test_scheduled_series_refresh.py index 8ef7daf0..2c838daa 100644 --- a/tests/integration/metadata_identity/test_scheduled_series_refresh.py +++ b/tests/integration/metadata_identity/test_scheduled_series_refresh.py @@ -13,7 +13,7 @@ from pullbox.core.metadata_identity import IdentityNamespace as Namespace from pullbox.core.metadata_identity import MetadataEntityKind as Kind from pullbox.core.metadata_identity import MetadataSource as Source -from pullbox.models import Issue, Series +from pullbox.models import Issue, MetadataSeriesRetry, Series from pullbox.models.issue import IssueStatus from pullbox.models.metadata_identity import SeriesExternalIdentity from pullbox.models.metadata_source import MetadataSourceConfig @@ -201,7 +201,7 @@ async def test_ineligible_series_never_contact_a_provider(scheduled, reason): SourceStatus.UNAVAILABLE, ], ) -async def test_scheduled_failure_is_atomic_and_stays_at_retry_cursor(scheduled, operation, status): +async def test_scheduled_failure_is_atomic_and_keeps_source_retry(scheduled, operation, status): factory, adapter, scheduler = scheduled series_id, data = await add_series(factory) offer(adapter, data) @@ -213,11 +213,18 @@ async def throttled(*args, **kwargs): setattr(adapter, operation, throttled) started = datetime.now(UTC).timestamp() result = await metadata_task.refresh_metadata() - assert result.status == "waiting", "Structured source throttles must keep the sweep resumable" + auth = status is SourceStatus.AUTHENTICATION_FAILED + assert result.status == ("completed" if auth else "waiting") async with factory() as session: state = await load_sweep(session, "refresh_metadata") - assert state.active and state.cursor == 0 and state.upper_bound == series_id - assert started + 719 <= state.retry_at <= datetime.now(UTC).timestamp() + 721 + assert state.cursor == state.upper_bound == series_id + retry = await session.scalar(select(MetadataSeriesRetry)) + assert retry.source == Source.METRON_API.value and retry.status == status.value + if auth: + assert not state.active and retry.retry_at is None + else: + assert state.active and retry.retry_at.timestamp() == state.retry_at + assert started + 719 <= state.retry_at <= datetime.now(UTC).timestamp() + 721 assert (await load_metadata_baseline(session, Kind.SERIES, series_id)).revision == 1 calls = list(adapter.calls) await metadata_task.refresh_metadata() @@ -329,7 +336,8 @@ async def fail_checkpoint(session, task_id, state): await original(session, task_id, state) monkeypatch.setattr(metadata_task, "save_sweep", fail_checkpoint) - await metadata_task.refresh_metadata() + with pytest.raises(RuntimeError, match="checkpoint failed before commit"): + await metadata_task.refresh_metadata() assert adapter.calls artwork.assert_not_awaited() scheduler._scheduler.add_job.assert_not_called() diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index a917cf30..ebc56249 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["u2o3p4q5r678"] + assert script.get_heads() == ["v3p4q5r6s789"] + assert script.get_revision("v3p4q5r6s789").down_revision == "u2o3p4q5r678" assert script.get_revision("u2o3p4q5r678").down_revision == "t1n2o3p4q567" assert script.get_revision("t1n2o3p4q567").down_revision == "s0m1n2o3p456" assert script.get_revision("s0m1n2o3p456").down_revision == "r9l0m1n2o345" diff --git a/tests/tasks/test_metadata_search_trigger.py b/tests/tasks/test_metadata_search_trigger.py index ee5d0735..729b6691 100644 --- a/tests/tasks/test_metadata_search_trigger.py +++ b/tests/tasks/test_metadata_search_trigger.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from pullbox.core.metadata_identity import MetadataSource -from pullbox.models import Base, Series +from pullbox.models import Base, MetadataSeriesRetry, Series from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh from pullbox.services.metadata_series_refresh import SeriesRefreshError @@ -48,11 +49,11 @@ def _make_metadata_svc(_unused): @contextlib.contextmanager def _sync_patches(factory, service, scheduler, *, search=False): - async def refresh(session, series_id): + async def refresh(session, series_id, *, registry): await service.refresh_series(session, series_id) return ScheduledSeriesRefresh(int(search), search, None, Path("/unused-test-covers")) - async def daily(session, series_id, *, refresh_days): + async def daily(session, series_id, *, refresh_days, registry): await service.fetch_series(session, series_id) return ScheduledSeriesRefresh(int(search), search, None, Path("/unused-test-covers")) @@ -208,7 +209,7 @@ async def test_bounded_sweep_keeps_initial_upper_bound_and_commits_searches( @pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) async def test_provider_retry_retains_cursor_and_survives_restart(restore_db_factory, task_id): factory = restore_db_factory - await _create_series(factory, comicvine_id=91001) + series_id = await _create_series(factory, comicvine_id=91001) service = _make_metadata_svc([]) operation = service.fetch_series if task_id == "sync_new_issues" else service.refresh_series operation.side_effect = SeriesRefreshError( @@ -228,7 +229,10 @@ async def test_provider_retry_retains_cursor_and_survives_restart(restore_db_fac operation.assert_awaited_once() async with factory() as session: state = await load_sweep(session, task_id) - assert state.cursor == 0 and state.active and state.retry_at > 0 + assert state.cursor == series_id and state.active and state.retry_at > 0 + retry = await session.scalar(select(MetadataSeriesRetry)) + assert retry.source == MetadataSource.METRON_API.value + assert retry.retry_at.timestamp() == state.retry_at scheduler._scheduler.add_job.assert_not_called() diff --git a/tests/tasks/test_scheduler_wrappers.py b/tests/tasks/test_scheduler_wrappers.py index 9655112f..79f8e15c 100644 --- a/tests/tasks/test_scheduler_wrappers.py +++ b/tests/tasks/test_scheduler_wrappers.py @@ -377,7 +377,9 @@ async def test_refresh_metadata_commits_each_series_and_continues_after_failures failing_id = series_two.id fresh_id = fresh_series.id - async def _refresh_series(session: AsyncSession, series_id: int) -> ScheduledSeriesRefresh: + async def _refresh_series( + session: AsyncSession, series_id: int, *, registry: object + ) -> ScheduledSeriesRefresh: series = await session.get(Series, series_id) assert series is not None if series_id == failing_id: From 8d678b6fa9e27cb82e48069d1e3d71e1db428a7c Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 16:57:37 -0700 Subject: [PATCH 52/97] feat(metadata): persist shared provider account admission Coordinate credential-scoped holds and bounded recovery probes across registry searches, metadata reads, scheduled tasks, and restarts. Keep admission transactions independent of library writes and preserve healthy local and cached reads. Add SQLite/PostgreSQL coverage for cancellation, stale outcomes, credential changes, feature gates, migration parity, and query bounds. Update retry fixtures to expire account and entity deadlines together and prove healthy-source progress during another account outage. --- ...q5r6s7t890_add_metadata_source_accounts.py | 46 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 15 +- docs/development/DATABASE_STANDARDS.md | 18 + src/pullbox/models/__init__.py | 2 + src/pullbox/models/metadata_source_account.py | 34 ++ .../services/metadata_account_admission.py | 200 ++++++++ src/pullbox/services/metadata_discovery.py | 24 +- src/pullbox/services/metadata_source_reads.py | 35 +- src/pullbox/services/metadata_sources.py | 12 +- .../test_isolated_metadata_retry.py | 49 +- .../test_source_account_admission.py | 442 ++++++++++++++++++ tests/integration/test_migrations.py | 3 +- 12 files changed, 870 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py create mode 100644 src/pullbox/models/metadata_source_account.py create mode 100644 src/pullbox/services/metadata_account_admission.py create mode 100644 tests/integration/metadata_identity/test_source_account_admission.py diff --git a/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py b/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py new file mode 100644 index 00000000..f9e55433 --- /dev/null +++ b/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py @@ -0,0 +1,46 @@ +"""Persist provider account cooldowns independently of individual series retries.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "w4q5r6s7t890" +down_revision = "v3p4q5r6s789" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "metadata_source_accounts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("source", sa.String(30), nullable=False, unique=True), + sa.Column("account_key", sa.String(64), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("status", sa.String(30)), + sa.Column("retry_at", sa.DateTime(timezone=True)), + sa.Column("lease_until", sa.DateTime(timezone=True)), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.CheckConstraint( + "source IN ('comicvine_api','metron_api','gcd_api_v2')", + name="ck_metadata_account_source", + ), + sa.CheckConstraint("revision > 0", name="ck_metadata_account_revision"), + sa.CheckConstraint( + "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR " + "(status IS NOT NULL AND status = 'authentication_failed' " + "AND retry_at IS NULL AND lease_until IS NULL) OR " + "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') " + "AND retry_at IS NOT NULL)", + name="ck_metadata_account_state", + ), + ) + + +def downgrade() -> None: + op.drop_table("metadata_source_accounts") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 9cc30916..ebd4e53d 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -700,8 +700,19 @@ failures have no timed retry: a changed source policy or credential scope admits them again. Only one-way configuration scope keys are stored, never credentials. Cancellation, failed commits and stale attempts cannot discard or resurrect settled retry work. Infrastructure lock failures retain a sweep-level pause. -Durable account-wide cooldown admission across different series remains separate -from these entity retries; existing provider clients retain process-local throttling. +Account-wide cooldown admission is separate from entity retries. The source +registry uses `metadata_source_accounts` for remote-source authentication holds, +rate limits and transient outages across tasks, series and process restarts. +Credential scope, not source priority, identifies the account. Local catalogs +and fresh response-cache hits do not depend on account admission. Each provider +read obtains its request slot before claiming a bounded recovery probe; one +probe runs after a cooldown expires and an abandoned probe expires after 45 +seconds. Cancellation releases its lease without clearing the previous failure. +Revision-checked outcome writes cannot clear a newer hold. Account transactions +are short and independent of library transactions; provider I/O starts only +after the admission transaction ends. Existing client request pacing remains +in force. Authentication-held work still requires corrected credentials; operator +retry visibility and deliberate re-probe controls remain separate follow-up work. Post-restore aftercare keeps its recovery marker while continuation batches remain active; it observes completion without retaining a database transaction between checks. Cancellation leaves the marker and sweep checkpoint available for the next startup. diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 369ec873..f4fda772 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -406,6 +406,24 @@ Health checks perform no provider I/O inside a database transaction. Their short result write applies only to the tested revision and cannot replace a newer probe. Cached health is diagnostic, not proof that a source is safe or available now. +### Metadata Account Admission + +`metadata_source_accounts` keeps at most one current credential-scope record per +remote source, separate from series retries, response caches and library metadata. +It stores a one-way account scope key, bounded status, UTC retry/probe deadlines +and a positive revision, not credentials or remote error payloads. Source policy +ordering changes cannot bypass a known account failure. Local catalogs have no +account row. Downgrade removes transport admission state only. + +The account guard is a transport lifecycle boundary with engine-bound sessions +of its own, like the source response cache. It never commits the calling library +session. Admission commits before provider I/O; outcomes use conditional updates +against the captured credential scope and revision. An expired cooldown admits +one 45-second recovery probe, acquired after local request capacity is available. +Cancellation releases the probe while retaining the prior failure; a crashed +process recovers through lease expiry. Reads fail closed if admission storage +cannot be checked, instead of repeatedly submitting known-failing requests. + ### Canonical Metadata Baselines Series, issue and Story Arc metadata baselines use separate real foreign keys, diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index eeedb4a2..70dd1247 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -75,6 +75,7 @@ ) from pullbox.models.metadata_series_retry import MetadataSeriesRetry from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.metadata_source_account import MetadataSourceAccount from pullbox.models.operation_progress import ( OperationProgress, OperationProgressState, @@ -203,6 +204,7 @@ "MatchingSuggestion", "MetadataProviderCacheEntry", "MetadataSeriesRetry", + "MetadataSourceAccount", "MetadataSourceConfig", "OperationProgress", "OperationProgressState", diff --git a/src/pullbox/models/metadata_source_account.py b/src/pullbox/models/metadata_source_account.py new file mode 100644 index 00000000..4d04afdc --- /dev/null +++ b/src/pullbox/models/metadata_source_account.py @@ -0,0 +1,34 @@ +"""Bounded account admission state, independent of catalog or entity progress.""" + +from datetime import datetime + +from sqlalchemy import CheckConstraint, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime + + +class MetadataSourceAccount(Base, IdentityMixin, TimestampMixin): + __tablename__ = "metadata_source_accounts" + __table_args__ = ( + CheckConstraint( + "source IN ('comicvine_api','metron_api','gcd_api_v2')", + name="ck_metadata_account_source", + ), + CheckConstraint("revision > 0", name="ck_metadata_account_revision"), + CheckConstraint( + "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR " + "(status IS NOT NULL AND status = 'authentication_failed' " + "AND retry_at IS NULL AND lease_until IS NULL) OR " + "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') " + "AND retry_at IS NOT NULL)", + name="ck_metadata_account_state", + ), + ) + + source: Mapped[str] = mapped_column(String(30), nullable=False, unique=True) + account_key: Mapped[str] = mapped_column(String(64), nullable=False) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + status: Mapped[str | None] = mapped_column(String(30)) + retry_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + lease_until: Mapped[datetime | None] = mapped_column(UTCDateTime) diff --git a/src/pullbox/services/metadata_account_admission.py b/src/pullbox/services/metadata_account_admission.py new file mode 100644 index 00000000..28f0a305 --- /dev/null +++ b/src/pullbox/services/metadata_account_admission.py @@ -0,0 +1,200 @@ +"""Short independent account-state transactions around, never across, provider I/O.""" + +from __future__ import annotations + +import asyncio +import hashlib +import math +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models.metadata_source_account import MetadataSourceAccount as Account +from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from pullbox.services.metadata_sources import SourceRuntime + +logger = structlog.get_logger(__name__) +_REMOTE = {MetadataSource.COMICVINE_API, MetadataSource.METRON_API, MetadataSource.GCD_API_V2} +_FAILURES = { + SourceStatus.RATE_LIMITED, + SourceStatus.TIMEOUT, + SourceStatus.UNAVAILABLE, + SourceStatus.AUTHENTICATION_FAILED, +} + + +def account_key(runtime: SourceRuntime) -> str: + # Account identity only, not password verification. Priority edits cannot reset a hold. + token = runtime.credential.get_secret_value() if runtime.credential else "" + return hashlib.sha256(f"{runtime.policy.source.value}:{token}".encode()).hexdigest() + + +@dataclass +class AccountAttempt: + blocked: SourceOutcome | None = None + key: str = "" + revision: int = 0 + probe: bool = False + started: bool = False + outcome: SourceOutcome | None = None + + +def source_account_admission( + session: AsyncSession, *, gcd_api_enabled: bool +) -> MetadataAccountAdmission | None: + if not isinstance(session.bind, AsyncEngine): + return None + return MetadataAccountAdmission( + async_sessionmaker(session.bind, class_=type(session), expire_on_commit=False), + gcd_api_enabled=gcd_api_enabled, + ) + + +@asynccontextmanager +async def account_request( + runtime: SourceRuntime, *, slots: asyncio.Semaphore, deadline: float +) -> AsyncIterator[AccountAttempt]: + async with asyncio.timeout_at(deadline): + await slots.acquire() + try: + gate = runtime.account_admission + if gate is None or runtime.policy.source not in _REMOTE or runtime.credential is None: + yield AccountAttempt() + return + attempt = await gate.admit(runtime) + try: + yield attempt + finally: + if attempt.blocked is None: + await gate.finish(runtime, attempt) + finally: + slots.release() + + +class MetadataAccountAdmission: + """Account transport state owns its sessions, never commits a library caller's work.""" + + def __init__(self, factory: async_sessionmaker[AsyncSession], *, gcd_api_enabled: bool) -> None: + self.factory = factory + self.gcd_api_enabled = gcd_api_enabled + + async def admit(self, runtime: SourceRuntime) -> AccountAttempt: + from pullbox.services.metadata_sources import load_source_runtime + + source, key = runtime.policy.source, account_key(runtime) + try: + async with asyncio.timeout(2), self.factory.begin() as session: + # Recheck a captured registry before it can reset another credential's state. + current = next( + item + for item in await load_source_runtime( + session, gcd_api_enabled=self.gcd_api_enabled + ) + if item.policy.source is source + ) + if current.unavailable or account_key(current) != key: + return AccountAttempt( + blocked=SourceOutcome( + source=source, + status=current.unavailable or SourceStatus.UNAVAILABLE, + retry_after_seconds=60, + ) + ) + insert = ( + pg_insert if session.get_bind().dialect.name == "postgresql" else sqlite_insert + ) + await session.execute( + insert(Account) + .values( + source=source.value, + account_key=key, + revision=1, + ) + .on_conflict_do_nothing(index_elements=[Account.source]) + ) + row = await session.scalar( + select(Account).where(Account.source == source.value).with_for_update() + ) + assert row is not None + if row.account_key != key: + row.account_key = key + row.status = row.retry_at = row.lease_until = None + row.revision += 1 + now = datetime.now(UTC) + if row.status == SourceStatus.AUTHENTICATION_FAILED.value: + return AccountAttempt( + blocked=SourceOutcome( + source=source, status=SourceStatus.AUTHENTICATION_FAILED + ) + ) + deadline = max( + (item for item in (row.retry_at, row.lease_until) if item), default=None + ) + if row.status is not None and deadline is not None and deadline > now: + return AccountAttempt( + blocked=SourceOutcome( + source=source, + status=SourceStatus(row.status), + retry_after_seconds=max(1, math.ceil((deadline - now).total_seconds())), + ) + ) + probe = row.status is not None + if probe: + row.lease_until = now + timedelta(seconds=45) + row.revision += 1 + return AccountAttempt(key=key, revision=row.revision, probe=probe) + except (SQLAlchemyError, TimeoutError): + logger.warning("metadata_account_admission_unavailable", source=source.value) + return AccountAttempt( + blocked=SourceOutcome( + source=source, status=SourceStatus.UNAVAILABLE, retry_after_seconds=5 + ) + ) + + async def finish(self, runtime: SourceRuntime, attempt: AccountAttempt) -> None: + outcome = attempt.outcome if attempt.started else None + failed = outcome is not None and outcome.status in _FAILURES + if not failed and not attempt.probe: + return + try: + async with asyncio.timeout(2), self.factory.begin() as session: + statement = ( + update(Account) + .where( + Account.source == runtime.policy.source.value, + Account.account_key == attempt.key, + Account.revision == attempt.revision, + ) + .values(lease_until=None, revision=Account.revision + 1) + ) + if failed and outcome is not None: + delay = outcome.retry_after_seconds or ( + 3600 if outcome.status is SourceStatus.RATE_LIMITED else 300 + ) + due = ( + None + if outcome.status is SourceStatus.AUTHENTICATION_FAILED + else (datetime.now(UTC) + timedelta(seconds=max(60, min(delay, 7 * 86400)))) + ) + statement = statement.values(status=outcome.status.value, retry_at=due) + elif outcome is not None: + statement = statement.values(status=None, retry_at=None) + # Cancellation only releases the probe lease, retaining the prior failure. + await session.execute(statement) + except (SQLAlchemyError, TimeoutError): + logger.warning( + "metadata_account_outcome_store_failed", source=runtime.policy.source.value + ) diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index b5f5bf91..1d1444f1 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -28,6 +28,7 @@ StoryArcDiscoveryRead, StoryArcSourceOutcome, ) +from pullbox.services.metadata_account_admission import AccountAttempt, account_request if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -173,15 +174,36 @@ async def _run( return SourcePage([]), SourceOutcome(source=source, status=unavailable) if asyncio.get_running_loop().time() >= deadline: return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + try: + async with account_request( + self.runtime[source], slots=semaphore, deadline=deadline + ) as attempt: + if attempt.blocked is not None: + return SourcePage([]), attempt.blocked + page, outcome = await self._run_admitted(source, query, deadline, handle, attempt) + attempt.outcome = outcome + return page, outcome + except TimeoutError: + return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + + async def _run_admitted( + self, + source: MetadataSource, + query: SeriesDiscoveryQuery | None, + deadline: float, + handle: _SourceHandle | None, + attempt: AccountAttempt, + ) -> tuple[SourcePage, SourceOutcome]: owns_handle = handle is None handle = handle or _SourceHandle() page = SourcePage([]) try: - async with asyncio.timeout_at(deadline), semaphore: + async with asyncio.timeout_at(deadline): async with asyncio.timeout(self.per_source_timeout): if handle.adapter is None: handle.adapter = self.factories[source].factory(self.runtime[source]) adapter = handle.adapter + attempt.started = True if query is None: await adapter.check() else: diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py index d21ae9ef..429bbf80 100644 --- a/src/pullbox/services/metadata_source_reads.py +++ b/src/pullbox/services/metadata_source_reads.py @@ -17,8 +17,10 @@ ProviderStoryArcRead, RecentIssueWindow, SourceCapability, + SourceOutcome, SourceStatus, ) +from pullbox.services.metadata_account_admission import AccountAttempt, account_request from pullbox.services.metadata_discovery import MetadataSourceError if TYPE_CHECKING: @@ -183,13 +185,44 @@ async def load(current: str | None) -> MetadataFetch[T]: if result.data is not None: validate(result.data) return result + try: + async with account_request( + runtime, slots=registry.read_slots, deadline=deadline + ) as attempt: + if attempt.blocked is not None: + return MetadataFetch( + status=attempt.blocked.status, + retry_after_seconds=attempt.blocked.retry_after_seconds, + ) + result = await _read_admitted( + registry, source, capability, operation, validate, validator, deadline, attempt + ) + attempt.outcome = SourceOutcome( + source=source, status=result.status, retry_after_seconds=result.retry_after_seconds + ) + return result + except TimeoutError: + return MetadataFetch(status=SourceStatus.TIMEOUT) + + +async def _read_admitted[T]( + registry: MetadataSourceRegistry, + source: MetadataSource, + capability: SourceCapability, + operation: Callable[[MetadataSourceAdapter, str | None], Awaitable[MetadataFetch[T]]], + validate: Callable[[T], None], + validator: str | None, + deadline: float, + attempt: AccountAttempt, +) -> MetadataFetch[T]: adapter = None try: if asyncio.get_running_loop().time() >= deadline: return MetadataFetch(status=SourceStatus.TIMEOUT) - async with asyncio.timeout_at(deadline), registry.read_slots: + async with asyncio.timeout_at(deadline): async with asyncio.timeout(registry.per_source_timeout): adapter = registry.factories[source].factory(registry.runtime[source]) + attempt.started = True result = await operation(adapter, validator) if result.status is SourceStatus.OK: if result.data is None: diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py index acdd59a8..8a02fd48 100644 --- a/src/pullbox/services/metadata_sources.py +++ b/src/pullbox/services/metadata_sources.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import PurePosixPath from typing import TYPE_CHECKING @@ -31,6 +31,8 @@ from sqlalchemy.ext.asyncio import AsyncSession + from pullbox.services.metadata_account_admission import MetadataAccountAdmission + class SourceConfigurationConflictError(ValueError): """Source policy was edited after the displayed revision.""" @@ -41,6 +43,9 @@ class SourceRuntime: policy: SourcePolicyRead credential: SecretStr | None = None unavailable: SourceStatus | None = None + account_admission: MetadataAccountAdmission | None = field( + default=None, repr=False, compare=False + ) def default_policy(source: MetadataSource) -> SourcePolicyRead: @@ -198,6 +203,9 @@ async def load_source_runtime( session: AsyncSession, *, gcd_api_enabled: bool ) -> list[SourceRuntime]: """Load configuration only. Disabled and flagged sources never decrypt secrets.""" + from pullbox.services.metadata_account_admission import source_account_admission + + admission = source_account_admission(session, gcd_api_enabled=gcd_api_enabled) policies = await read_source_policies(session) rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))} result = [] @@ -222,7 +230,7 @@ async def load_source_runtime( credential = SecretStr(token) if token else None except ValueError: status = SourceStatus.INVALID_CONFIG - result.append(SourceRuntime(policy, credential, status)) + result.append(SourceRuntime(policy, credential, status, admission)) return result diff --git a/tests/integration/metadata_identity/test_isolated_metadata_retry.py b/tests/integration/metadata_identity/test_isolated_metadata_retry.py index 7174056b..aaa4a42d 100644 --- a/tests/integration/metadata_identity/test_isolated_metadata_retry.py +++ b/tests/integration/metadata_identity/test_isolated_metadata_retry.py @@ -11,6 +11,7 @@ from pullbox.core.metadata_identity import MetadataSource as Source from pullbox.models import Base, MetadataSeriesRetry, Series from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.metadata_source_account import MetadataSourceAccount from pullbox.models.series import IssueCatalogState from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus from pullbox.services.metadata_discovery import MetadataSourceError @@ -37,6 +38,11 @@ async def isolated(daily): # noqa: F811 - imported pytest fixture async def expire_retry(factory, task_id): async with factory.begin() as session: + await session.execute( + update(MetadataSourceAccount) + .where(MetadataSourceAccount.retry_at.isnot(None)) + .values(retry_at=datetime.now(UTC) - timedelta(seconds=1)) + ) await session.execute( update(MetadataSeriesRetry) .where( @@ -54,9 +60,17 @@ async def expire_retry(factory, task_id): async def test_retryable_series_does_not_block_the_next_series(isolated, monkeypatch, task_id): factory, _, scheduler = isolated first, first_data = await add_series(factory) - second, second_data = await add_series(factory, identifier=43) + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.COMICVINE_LOCAL.value) + .values(enabled=True) + ) + second, second_data = await add_series(factory, identifier=43, source=Source.COMICVINE_LOCAL) adapter = DailyAdapter(first_data) - offer(adapter, second_data) + local = DailyAdapter(second_data) + local.source = Source.COMICVINE_LOCAL + offer(local, second_data) original = adapter.series async def fetch(identifier, **kwargs): @@ -68,7 +82,11 @@ async def fetch(identifier, **kwargs): adapter.series = fetch from pullbox.providers.metadata import sources - monkeypatch.setattr(sources, "metadata_sources", lambda: registry(adapter).factories) + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: {**registry(adapter).factories, **registry(local).factories}, + ) result = await getattr(metadata_task, task_id)() async with factory() as session: assert (await session.get(Series, second)).issue_count == 2, ( @@ -326,3 +344,28 @@ async def test_hydrating_retry_does_not_hide_ready_work_behind_page_limit(isolat ) async with factory() as session: assert await retry_candidates(session, "refresh_metadata", [], limit=1) == [second] + + +@pytest.mark.parametrize("failure", [SourceStatus.RATE_LIMITED, SourceStatus.AUTHENTICATION_FAILED]) +async def test_account_failure_is_shared_across_series_and_both_scheduled_tasks(isolated, failure): + factory, adapter, _ = isolated + first, _ = await add_series(factory) + second, _ = await add_series(factory, identifier=43) + + async def failed(identifier, **kwargs): + adapter.calls.append(("failed", identifier)) + raise MetadataSourceError(failure, retry_after_seconds=720) + + adapter.series = failed + await metadata_task.sync_new_issues() + await factory.kw["bind"].dispose() + await metadata_task.refresh_metadata() + assert adapter.calls == [("failed", "42")], "The second task and series share account admission" + async with factory() as session: + retries = list(await session.scalars(select(MetadataSeriesRetry))) + assert {(row.task_id, row.series_id) for row in retries} == { + (task_id, series_id) + for task_id in ("sync_new_issues", "refresh_metadata") + for series_id in (first, second) + } + assert {row.status for row in retries} == {failure.value} diff --git a/tests/integration/metadata_identity/test_source_account_admission.py b/tests/integration/metadata_identity/test_source_account_admission.py new file mode 100644 index 00000000..e0bb3eda --- /dev/null +++ b/tests/integration/metadata_identity/test_source_account_admission.py @@ -0,0 +1,442 @@ +"""Provider account failures are durable and shared, without hiding healthy sources.""" + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import event, select, update +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from pullbox.core.encryption import encrypt_secret +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.metadata_source_account import MetadataSourceAccount as Account +from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery, SourceCapability, SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry +from pullbox.services.metadata_sources import load_source_runtime +from tests.integration.metadata_identity.test_series_adoption import ( + configured_sources, # noqa: F401 +) +from tests.unit.test_metadata_discovery import Adapter, registration +from tests.unit.test_metadata_source_reads import ReadAdapter + + +@pytest.fixture +async def accounts(identity_probe_db): + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.METRON_API.value) + .values(credential_secret=encrypt_secret("account-test-token")) + ) + return engine, factory + + +async def reader(factory, *adapters): + async with factory() as session: + runtime = await load_source_runtime(session, gcd_api_enabled=False) + return MetadataSourceRegistry( + runtime, + factories={ + item.source: registration(item, capabilities=list(SourceCapability)) + for item in adapters + }, + ) + + +@pytest.mark.parametrize( + "failure", + [ + SourceStatus.RATE_LIMITED, + SourceStatus.TIMEOUT, + SourceStatus.UNAVAILABLE, + SourceStatus.AUTHENTICATION_FAILED, + ], +) +async def test_failure_holds_other_series_after_registry_and_engine_restart(accounts, failure): + engine, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(failure, retry_after_seconds=720)) + assert (await (await reader(factory, adapter)).series(adapter.source, "42")).status is failure + await engine.dispose() + result = await (await reader(factory, adapter)).series(adapter.source, "43") + assert result.status is failure + assert len(adapter.calls) == 1, "Known account failures must not be retried for every series" + if failure is not SourceStatus.AUTHENTICATION_FAILED: + assert 0 < result.retry_after_seconds <= 720 + + +async def test_search_failure_is_shared_with_exact_reads_but_local_source_still_works(accounts): + _, factory = accounts + remote = ReadAdapter(error=MetadataSourceError(SourceStatus.RATE_LIMITED, 720)) + local = Adapter(Source.COMICVINE_LOCAL) + first = await (await reader(factory, remote, local)).discover( + SeriesDiscoveryQuery(query="Example", sources=[remote.source, local.source]) + ) + assert len(first.results) == 1 and first.results[0].source is local.source + result = await (await reader(factory, remote)).series(remote.source, "42") + assert result.status is SourceStatus.RATE_LIMITED + assert remote.calls == [0], "Search and catalog reads must share the same account guard" + + +async def test_priority_edit_does_not_bypass_authentication_hold_but_new_token_does(accounts): + _, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED)) + await (await reader(factory, adapter)).series(adapter.source, "42") + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == adapter.source.value) + .values(priority=12, revision=2) + ) + adapter.error = None + assert (await (await reader(factory, adapter)).series(adapter.source, "42")).status is ( + SourceStatus.AUTHENTICATION_FAILED + ), "Changing priority must not repeatedly submit the same rejected token" + assert len(adapter.calls) == 1 + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == adapter.source.value) + .values(credential_secret=encrypt_secret("replacement-account-token")) + ) + assert (await (await reader(factory, adapter)).series(adapter.source, "42")).status is ( + SourceStatus.OK + ) + assert len(adapter.calls) == 2 + + +async def expire_account(factory): + async with factory.begin() as session: + await session.execute( + update(Account).values(retry_at=datetime.now(UTC) - timedelta(seconds=1)) + ) + + +async def throttled_account(accounts): + _, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(SourceStatus.RATE_LIMITED, 720)) + await (await reader(factory, adapter)).series(adapter.source, "42") + await expire_account(factory) + adapter.error = None + adapter.started.clear() + return factory, adapter + + +async def test_expired_cooldown_allows_one_probe_and_success_reopens_account(accounts): + factory, adapter = await throttled_account(accounts) + adapter.wait = asyncio.Event() + registry = await reader(factory, adapter) + task = asyncio.create_task(registry.series(adapter.source, "42")) + try: + await asyncio.wait_for(adapter.started.wait(), 3) + result = await (await reader(factory, adapter)).series(adapter.source, "43") + assert result.status is SourceStatus.RATE_LIMITED + assert 0 < result.retry_after_seconds <= 45 + assert len(adapter.calls) == 2, "Only one expired-account probe may reach the provider" + adapter.wait.set() + assert (await task).status is SourceStatus.OK + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert ( + await (await reader(factory, adapter)).series(adapter.source, "42") + ).status is SourceStatus.OK + async with factory() as session: + row = await session.scalar(select(Account)) + assert row.status is None and row.retry_at is None and row.lease_until is None + + +async def test_cancelled_probe_releases_lease_without_discarding_prior_failure(accounts): + factory, adapter = await throttled_account(accounts) + adapter.wait = asyncio.Event() + task = asyncio.create_task((await reader(factory, adapter)).series(adapter.source, "42")) + try: + await asyncio.wait_for(adapter.started.wait(), 3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + row = await session.scalar(select(Account)) + assert row.status == "rate_limited" and row.lease_until is None + adapter.wait = None + assert ( + await (await reader(factory, adapter)).series(adapter.source, "42") + ).status is SourceStatus.OK + + +async def test_abandoned_probe_recovers_after_lease_expiry(accounts): + factory, adapter = await throttled_account(accounts) + instance = await reader(factory, adapter) + runtime = instance.runtime[adapter.source] + permit = await runtime.account_admission.admit(runtime) + assert permit.probe + assert (await instance.series(adapter.source, "42")).status is SourceStatus.RATE_LIMITED + async with factory.begin() as session: + await session.execute( + update(Account).values(lease_until=datetime.now(UTC) - timedelta(seconds=1)) + ) + assert (await instance.series(adapter.source, "42")).status is SourceStatus.OK + + +async def test_probe_is_not_claimed_while_waiting_for_local_request_capacity(accounts, monkeypatch): + factory, adapter = await throttled_account(accounts) + instance = await reader(factory, adapter) + instance.read_slots = asyncio.Semaphore(0) + gate = instance.runtime[adapter.source].account_admission + original = gate.admit + entered = asyncio.Event() + + async def admission(runtime): + entered.set() + return await original(runtime) + + monkeypatch.setattr(gate, "admit", admission) + task = asyncio.create_task(instance.series(adapter.source, "42")) + try: + await asyncio.sleep(0) + assert not entered.is_set(), "A queued read must not spend or outlive its probe lease" + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def test_stale_probe_outcome_cannot_clear_or_restore_newer_state(accounts): + from pullbox.schemas.metadata_sources import SourceOutcome + + factory, adapter = await throttled_account(accounts) + runtime = (await reader(factory, adapter)).runtime[adapter.source] + obsolete = await runtime.account_admission.admit(runtime) + obsolete.started = True + async with factory.begin() as session: + await session.execute( + update(Account).values(lease_until=datetime.now(UTC) - timedelta(seconds=1)) + ) + newer = await runtime.account_admission.admit(runtime) + newer.started = True + newer.outcome = SourceOutcome(source=adapter.source, status=SourceStatus.AUTHENTICATION_FAILED) + await runtime.account_admission.finish(runtime, newer) + obsolete.outcome = SourceOutcome(source=adapter.source, status=SourceStatus.OK) + await runtime.account_admission.finish(runtime, obsolete) + async with factory() as session: + assert (await session.scalar(select(Account))).status == "authentication_failed" + + +async def test_late_success_cannot_clear_a_newer_account_failure(accounts): + _, factory = accounts + healthy = ReadAdapter(wait=asyncio.Event()) + task = asyncio.create_task((await reader(factory, healthy)).series(healthy.source, "42")) + try: + await asyncio.wait_for(healthy.started.wait(), 3) + failing = ReadAdapter(error=MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED)) + await (await reader(factory, failing)).series(failing.source, "43") + healthy.wait.set() + assert (await task).status is SourceStatus.OK + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert ( + await (await reader(factory, healthy)).series(healthy.source, "42") + ).status is SourceStatus.AUTHENTICATION_FAILED + assert len(healthy.calls) == 1 + + +async def test_obsolete_runtime_cannot_restore_a_replaced_credential(accounts): + _, factory = accounts + adapter = ReadAdapter() + obsolete = await reader(factory, adapter) + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where( + MetadataSourceConfig.source == adapter.source.value, + ) + .values(credential_secret=encrypt_secret("newer-account-token")) + ) + current = await reader(factory, adapter) + assert (await current.series(adapter.source, "42")).status is SourceStatus.OK + assert (await obsolete.series(adapter.source, "42")).status is SourceStatus.UNAVAILABLE + assert len(adapter.calls) == 1 + assert (await current.series(adapter.source, "42")).status is SourceStatus.OK + + +async def test_blocked_checks_do_not_extend_the_cooldown(accounts): + _, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(SourceStatus.UNAVAILABLE, 720)) + await (await reader(factory, adapter)).series(adapter.source, "42") + async with factory() as session: + row = await session.scalar(select(Account)) + previous = row.retry_at, row.revision + assert "account-test-token" not in row.account_key + for _ in range(3): + await (await reader(factory, adapter)).series(adapter.source, "43") + async with factory() as session: + row = await session.scalar(select(Account)) + assert (row.retry_at, row.revision) == previous + assert len(adapter.calls) == 1 + + +@pytest.mark.parametrize("status", [SourceStatus.NOT_FOUND, SourceStatus.INCOMPATIBLE_RESPONSE]) +async def test_entity_errors_do_not_block_other_requests(accounts, status): + _, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(status)) + await (await reader(factory, adapter)).series(adapter.source, "42") + adapter.error = None + assert ( + await (await reader(factory, adapter)).series(adapter.source, "42") + ).status is SourceStatus.OK + assert len(adapter.calls) == 2 + + +async def test_missing_admission_database_fails_closed_without_provider_io(accounts, monkeypatch): + _, factory = accounts + adapter = ReadAdapter() + instance = await reader(factory, adapter) + gate = instance.runtime[adapter.source].account_admission + + def unavailable(): + raise SQLAlchemyError("private-database-detail") + + monkeypatch.setattr(gate.factory, "begin", unavailable) + result = await instance.series(adapter.source, "42") + assert result.status is SourceStatus.UNAVAILABLE and result.retry_after_seconds == 5 + assert adapter.calls == [] + + +async def test_fresh_cached_metadata_remains_readable_without_bypassing_http_hold(accounts): + from pullbox.services.metadata_read_cache import MetadataReadCache + + _, factory = accounts + adapter = ReadAdapter() + instance = await reader(factory, adapter) + instance.read_cache = MetadataReadCache(factory) + assert (await instance.series(adapter.source, "42")).status is SourceStatus.OK + adapter.error = MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED) + await (await reader(factory, adapter)).series(adapter.source, "43") + cached = await reader(factory, adapter) + cached.read_cache = MetadataReadCache(factory) + assert (await cached.series(adapter.source, "42")).status is SourceStatus.OK + cached.revalidate_reads = True + assert (await cached.series(adapter.source, "42")).status is SourceStatus.AUTHENTICATION_FAILED + assert len(adapter.calls) == 2 + + +async def test_admission_releases_database_before_io_and_has_bounded_query_cost(accounts): + engine, factory = accounts + adapter = ReadAdapter() + instance = await reader(factory, adapter) + original = adapter.series + statements = [] + + async def fetch(*args, **kwargs): + assert engine.pool.checkedout() == 0, ( + "Account admission cannot hold a connection during HTTP" + ) + return await original(*args, **kwargs) + + def record(*args): + statements.append(args[2]) + + adapter.series = fetch + event.listen(engine.sync_engine, "before_cursor_execute", record) + try: + assert (await instance.series(adapter.source, "42")).status is SourceStatus.OK + finally: + event.remove(engine.sync_engine, "before_cursor_execute", record) + assert len(statements) <= 10 + + +async def test_local_sources_do_not_create_or_depend_on_account_state(accounts): + _, factory = accounts + local = Adapter(Source.COMICVINE_LOCAL) + result = await (await reader(factory, local)).discover( + SeriesDiscoveryQuery(query="Example", sources=[local.source]) + ) + assert len(result.results) == 1 + async with factory() as session: + assert list(await session.scalars(select(Account))) == [] + + +async def test_account_recheck_does_not_decrypt_a_feature_disabled_source(accounts, monkeypatch): + from pullbox.services import metadata_sources + + _, factory = accounts + hidden = encrypt_secret("feature-disabled-gcd-token") + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where( + MetadataSourceConfig.source == Source.GCD_API_V2.value, + ) + .values(credential_secret=hidden) + ) + adapter = ReadAdapter() + instance = await reader(factory, adapter) + decrypted = [] + original = metadata_sources.decrypt_secret + + def decrypt(value): + decrypted.append(value) + return original(value) + + monkeypatch.setattr(metadata_sources, "decrypt_secret", decrypt) + assert (await instance.series(adapter.source, "42")).status is SourceStatus.OK + assert hidden not in decrypted, "Account admission must preserve the GCD release flag" + + +async def test_account_schema_rejects_invalid_healthy_deadline(accounts): + _, factory = accounts + async with factory() as session: + session.add( + Account( + source=Source.METRON_API.value, + account_key="a" * 64, + status=None, + retry_at=datetime.now(UTC), + ) + ) + with pytest.raises(IntegrityError): + await session.flush() + await session.rollback() + + +async def test_account_migration_matches_model_and_preserves_library(identity_probe_db): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import MetaData + + from pullbox.models import Base, Series + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory, _ = identity_probe_db + async with factory.begin() as session: + series = Series(title="Preserved account migration", sort_title="Preserved") + session.add(series) + await session.flush() + identifier = series.id + async with engine.begin() as connection: + + def migrate(sync): + revision = _revision("w4q5r6s7t890_add_metadata_source_accounts", sync) + revision.downgrade() + revision.upgrade() + expected = MetaData() + name = "metadata_source_accounts" + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, item, type_, reflected, compare_to: ( + item == name if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + assert compare_metadata(context, expected) == [] + + await connection.run_sync(migrate) + async with factory() as session: + assert (await session.get(Series, identifier)).title == "Preserved account migration" diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index ebc56249..2a209af3 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["v3p4q5r6s789"] + assert script.get_heads() == ["w4q5r6s7t890"] + assert script.get_revision("w4q5r6s7t890").down_revision == "v3p4q5r6s789" assert script.get_revision("v3p4q5r6s789").down_revision == "u2o3p4q5r678" assert script.get_revision("u2o3p4q5r678").down_revision == "t1n2o3p4q567" assert script.get_revision("t1n2o3p4q567").down_revision == "s0m1n2o3p456" From 5400b20f6cf7e8767e57f0f633d16a29a25178a4 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 17:30:24 -0700 Subject: [PATCH 53/97] feat(metadata): expose deferred work and recover account access --- ...01_allow_metadata_authentication_probes.py | 44 +++ src/pullbox/api/v1/metadata_sources.py | 33 +- src/pullbox/models/metadata_source_account.py | 2 +- src/pullbox/schemas/metadata_sources.py | 19 + .../services/metadata_account_admission.py | 90 ++++- src/pullbox/services/metadata_discovery.py | 12 +- src/pullbox/services/metadata_series_retry.py | 33 ++ .../services/metadata_source_status.py | 131 +++++++ src/pullbox/tasks/metadata_sweep_state.py | 9 + src/pullbox/ui/settings_routes.py | 7 +- src/pullbox/ui/static/js/metadata-sources.js | 53 ++- .../partials/settings_metadata_deferred.html | 24 ++ .../partials/settings_metadata_sources.html | 11 + tests/api/test_metadata_recovery_api.py | 134 +++++++ tests/e2e/test_metadata_sources_settings.py | 87 +++++ .../test_account_recovery.py | 344 ++++++++++++++++++ .../test_isolated_metadata_retry.py | 88 +++++ .../test_source_account_admission.py | 5 +- tests/integration/test_migrations.py | 3 +- 19 files changed, 1103 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py create mode 100644 src/pullbox/services/metadata_source_status.py create mode 100644 src/pullbox/ui/templates/partials/settings_metadata_deferred.html create mode 100644 tests/api/test_metadata_recovery_api.py create mode 100644 tests/integration/metadata_identity/test_account_recovery.py diff --git a/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py b/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py new file mode 100644 index 00000000..c66d0730 --- /dev/null +++ b/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py @@ -0,0 +1,44 @@ +"""Allow a bounded deliberate probe without discarding an authentication hold.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "x5r6s7t8u901" +down_revision = "w4q5r6s7t890" +branch_labels = None +depends_on = None + + +def _constraint(*, allow_probe: bool) -> None: + auth_lease = "" if allow_probe else " AND lease_until IS NULL" + with op.batch_alter_table("metadata_source_accounts") as batch: + batch.drop_constraint("ck_metadata_account_state", type_="check") + batch.create_check_constraint( + "ck_metadata_account_state", + "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR " + "(status IS NOT NULL AND status = 'authentication_failed' " + f"AND retry_at IS NULL{auth_lease}) OR " + "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') " + "AND retry_at IS NOT NULL)", + ) + + +def upgrade() -> None: + _constraint(allow_probe=True) + + +def downgrade() -> None: + # Retain the authentication hold; only the unsupported active probe is dropped. + account = sa.table( + "metadata_source_accounts", + sa.column("status"), + sa.column("lease_until"), + sa.column("revision"), + ) + op.execute( + account.update() + .where(account.c.status == "authentication_failed", account.c.lease_until.isnot(None)) + .values(lease_until=None, revision=account.c.revision + 1) + ) + _constraint(allow_probe=False) diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py index 36d476a4..718413ee 100644 --- a/src/pullbox/api/v1/metadata_sources.py +++ b/src/pullbox/api/v1/metadata_sources.py @@ -4,13 +4,14 @@ from typing import Literal import structlog -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from pullbox.api.deps import AuthenticatedUser, DbSession, InteractiveOperatorUser, Settings from pullbox.core.exceptions import ConfigurationError from pullbox.core.metadata_identity import MetadataSource from pullbox.models.library import LibraryRoot from pullbox.schemas.metadata_sources import ( + DeferredMetadataRead, MetadataFetch, MetadataPage, ProviderIssueRead, @@ -30,10 +31,12 @@ StoryArcPreviewQuery, StoryArcPreviewRead, ) +from pullbox.schemas.pagination import PaginatedResponse from pullbox.services.metadata_arc_preview import preview_source_arc -from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies +from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_series_preview import preview_series_folder, preview_source_series +from pullbox.services.metadata_source_status import deferred_work, source_status from pullbox.services.metadata_sources import ( SourceConfigurationConflictError, load_source_runtime, @@ -184,8 +187,24 @@ async def series_issues( async def sources( session: DbSession, _user: InteractiveOperatorUser, settings: Settings ) -> list[SourceDescriptor]: - return describe_source_policies( - await read_source_policies(session), gcd_api_enabled=settings.metadata_gcd_api_v2_enabled + return await source_status(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) + + +@router.get("/retries", response_model=PaginatedResponse[DeferredMetadataRead]) +async def retries( + session: DbSession, + _user: InteractiveOperatorUser, + settings: Settings, + source: MetadataSource | None = None, + limit: int = Query(default=10, ge=1, le=100), + offset: int = Query(default=0, ge=0, le=1_000_000), +) -> PaginatedResponse[DeferredMetadataRead]: + return await deferred_work( + session, + source=source, + limit=limit, + offset=offset, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, ) @@ -194,11 +213,11 @@ async def save_priorities( body: SourcePriorityWrite, session: DbSession, user: InteractiveOperatorUser, settings: Settings ) -> list[SourceDescriptor]: try: - policies = await save_source_priorities(session, body) + await save_source_priorities(session, body) except SourceConfigurationConflictError as exc: raise HTTPException(409, str(exc)) from exc logger.info("metadata_source_priorities_updated", user_id=user.id) - return describe_source_policies(policies, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) + return await source_status(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled) @router.put("/sources/{source}", response_model=SourcePolicyRead) @@ -251,7 +270,7 @@ async def test_source( checked_at = datetime.now(UTC) outcome = await MetadataSourceRegistry( runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled - ).check(source) + ).check(source, retry_authentication=True) recorded = await record_source_health(session, source, revision, outcome, checked_at) logger.info( "metadata_source_test_complete", diff --git a/src/pullbox/models/metadata_source_account.py b/src/pullbox/models/metadata_source_account.py index 4d04afdc..876f28e7 100644 --- a/src/pullbox/models/metadata_source_account.py +++ b/src/pullbox/models/metadata_source_account.py @@ -19,7 +19,7 @@ class MetadataSourceAccount(Base, IdentityMixin, TimestampMixin): CheckConstraint( "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR " "(status IS NOT NULL AND status = 'authentication_failed' " - "AND retry_at IS NULL AND lease_until IS NULL) OR " + "AND retry_at IS NULL) OR " "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') " "AND retry_at IS NOT NULL)", name="ck_metadata_account_state", diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index 24444d41..a044bd32 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -326,9 +326,28 @@ class StoryArcDiscoveryRead(BaseModel): sources: list[StoryArcSourceOutcome] +class SourceAccountRead(BaseModel): + status: SourceStatus | None + retry_at: datetime | None + probe_until: datetime | None + + +class DeferredMetadataRead(BaseModel): + id: int + series_id: int + series_title: str + source: str + task_id: Literal["refresh_metadata", "sync_new_issues"] + state: Literal["ready", "waiting", "authentication_required", "source_disabled"] + retry_at: datetime | None + + class SourceDescriptor(SourcePolicyRead): capabilities: list[SourceCapability] availability: SourceStatus | None = None + account: SourceAccountRead | None = None + deferred_series: int = 0 + deferred_work: int = 0 class SourceTestRead(BaseModel): diff --git a/src/pullbox/services/metadata_account_admission.py b/src/pullbox/services/metadata_account_admission.py index 28f0a305..5dd51938 100644 --- a/src/pullbox/services/metadata_account_admission.py +++ b/src/pullbox/services/metadata_account_admission.py @@ -50,6 +50,8 @@ class AccountAttempt: probe: bool = False started: bool = False outcome: SourceOutcome | None = None + authentication_probe: bool = False + config_key: str = "" def source_account_admission( @@ -65,7 +67,11 @@ def source_account_admission( @asynccontextmanager async def account_request( - runtime: SourceRuntime, *, slots: asyncio.Semaphore, deadline: float + runtime: SourceRuntime, + *, + slots: asyncio.Semaphore, + deadline: float, + retry_authentication: bool = False, ) -> AsyncIterator[AccountAttempt]: async with asyncio.timeout_at(deadline): await slots.acquire() @@ -74,7 +80,7 @@ async def account_request( if gate is None or runtime.policy.source not in _REMOTE or runtime.credential is None: yield AccountAttempt() return - attempt = await gate.admit(runtime) + attempt = await gate.admit(runtime, retry_authentication=retry_authentication) try: yield attempt finally: @@ -91,7 +97,11 @@ def __init__(self, factory: async_sessionmaker[AsyncSession], *, gcd_api_enabled self.factory = factory self.gcd_api_enabled = gcd_api_enabled - async def admit(self, runtime: SourceRuntime) -> AccountAttempt: + async def admit( + self, runtime: SourceRuntime, *, retry_authentication: bool = False + ) -> AccountAttempt: + from pullbox.models.metadata_series_retry import MetadataSeriesRetry + from pullbox.services.metadata_series_retry import config_key from pullbox.services.metadata_sources import load_source_runtime source, key = runtime.policy.source, account_key(runtime) @@ -134,7 +144,23 @@ async def admit(self, runtime: SourceRuntime) -> AccountAttempt: row.status = row.retry_at = row.lease_until = None row.revision += 1 now = datetime.now(UTC) - if row.status == SourceStatus.AUTHENTICATION_FAILED.value: + if ( + retry_authentication + and row.status is None + and await session.scalar( + select(MetadataSeriesRetry.id) + .where( + MetadataSeriesRetry.source == source.value, + MetadataSeriesRetry.config_key == config_key(current), + MetadataSeriesRetry.status == SourceStatus.AUTHENTICATION_FAILED.value, + ) + .limit(1) + ) + ): + # Older deferred work can predate the account-state table. + row.status = SourceStatus.AUTHENTICATION_FAILED.value + authentication_probe = row.status == SourceStatus.AUTHENTICATION_FAILED.value + if authentication_probe and not retry_authentication: return AccountAttempt( blocked=SourceOutcome( source=source, status=SourceStatus.AUTHENTICATION_FAILED @@ -155,7 +181,13 @@ async def admit(self, runtime: SourceRuntime) -> AccountAttempt: if probe: row.lease_until = now + timedelta(seconds=45) row.revision += 1 - return AccountAttempt(key=key, revision=row.revision, probe=probe) + return AccountAttempt( + key=key, + revision=row.revision, + probe=probe, + authentication_probe=authentication_probe, + config_key=config_key(current), + ) except (SQLAlchemyError, TimeoutError): logger.warning("metadata_account_admission_unavailable", source=source.value) return AccountAttempt( @@ -165,12 +197,16 @@ async def admit(self, runtime: SourceRuntime) -> AccountAttempt: ) async def finish(self, runtime: SourceRuntime, attempt: AccountAttempt) -> None: + from pullbox.models.metadata_series_retry import MetadataSeriesRetry + outcome = attempt.outcome if attempt.started else None failed = outcome is not None and outcome.status in _FAILURES if not failed and not attempt.probe: return + wake_tasks: list[str] = [] try: async with asyncio.timeout(2), self.factory.begin() as session: + due = None statement = ( update(Account) .where( @@ -190,10 +226,50 @@ async def finish(self, runtime: SourceRuntime, attempt: AccountAttempt) -> None: else (datetime.now(UTC) + timedelta(seconds=max(60, min(delay, 7 * 86400)))) ) statement = statement.values(status=outcome.status.value, retry_at=due) - elif outcome is not None: + elif outcome is not None and ( + not attempt.authentication_probe or outcome.status is SourceStatus.OK + ): statement = statement.values(status=None, retry_at=None) # Cancellation only releases the probe lease, retaining the prior failure. - await session.execute(statement) + changed = await session.scalar(statement.returning(Account.id)) + if ( + changed is not None + and attempt.authentication_probe + and outcome is not None + and (outcome.status is SourceStatus.OK or due is not None) + ): + criteria = ( + MetadataSeriesRetry.source == runtime.policy.source.value, + MetadataSeriesRetry.config_key == attempt.config_key, + MetadataSeriesRetry.status == SourceStatus.AUTHENTICATION_FAILED.value, + ) + wake_tasks = list( + await session.scalars( + select(MetadataSeriesRetry.task_id) + .where(MetadataSeriesRetry.source == runtime.policy.source.value) + .distinct() + ) + ) + await session.execute( + update(MetadataSeriesRetry) + .where(*criteria) + .values( + status=( + outcome.status.value if due else SourceStatus.UNAVAILABLE.value + ), + retry_at=due or datetime.now(UTC), + revision=MetadataSeriesRetry.revision + 1, + ) + ) + if wake_tasks: + from pullbox.tasks.metadata_sweep_state import MetadataSweep, schedule_sweep + + for task_id in wake_tasks: + try: + schedule_sweep(task_id, MetadataSweep(active=True)) + except Exception: + # Durable retry deadlines also restore this wakeup after restart. + logger.warning("metadata_retry_wakeup_failed", task_id=task_id) except (SQLAlchemyError, TimeoutError): logger.warning( "metadata_account_outcome_store_failed", source=runtime.policy.source.value diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index 1d1444f1..86ed8e28 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -168,6 +168,8 @@ async def _run( semaphore: asyncio.Semaphore, deadline: float, handle: _SourceHandle | None = None, + *, + retry_authentication: bool = False, ) -> tuple[SourcePage, SourceOutcome]: unavailable = self._unavailable(source, search=query is not None) if unavailable is not None: @@ -176,7 +178,10 @@ async def _run( return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT) try: async with account_request( - self.runtime[source], slots=semaphore, deadline=deadline + self.runtime[source], + slots=semaphore, + deadline=deadline, + retry_authentication=retry_authentication, ) as attempt: if attempt.blocked is not None: return SourcePage([]), attempt.blocked @@ -344,12 +349,15 @@ async def discover( await asyncio.gather(*tasks, return_exceptions=True) return self._group_pages(pages) - async def check(self, source: MetadataSource) -> SourceOutcome: + async def check( + self, source: MetadataSource, *, retry_authentication: bool = False + ) -> SourceOutcome: _, outcome = await self._run( source, None, asyncio.Semaphore(1), asyncio.get_running_loop().time() + self.total_timeout, + retry_authentication=retry_authentication, ) return outcome diff --git a/src/pullbox/services/metadata_series_retry.py b/src/pullbox/services/metadata_series_retry.py index 85d8002d..e002acc6 100644 --- a/src/pullbox/services/metadata_series_retry.py +++ b/src/pullbox/services/metadata_series_retry.py @@ -10,8 +10,10 @@ from sqlalchemy.sql.elements import ColumnElement from pullbox.models import MetadataSeriesRetry, Series +from pullbox.models.metadata_source_account import MetadataSourceAccount from pullbox.models.series import IssueCatalogState from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus +from pullbox.services.metadata_account_admission import account_key from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_sources import SourceRuntime, load_source_runtime from pullbox.services.metadata_writer_identity import metadata_write_scope @@ -179,6 +181,37 @@ async def settle_retry( "", ) async with metadata_write_scope(session): + # A failure may settle after an explicit account probe has already recovered. + auth_sources = { + source + for source, (status, _, _) in pending.items() + if status == SourceStatus.AUTHENTICATION_FAILED.value + } + if auth_sources: + current = { + item.policy.source.value: item + for item in await retry_runtime( + session, gcd_api_enabled=admission.registry.gcd_api_enabled + ) + } + for account in await session.scalars( + select(MetadataSourceAccount) + .where(MetadataSourceAccount.source.in_(auth_sources)) + .order_by(MetadataSourceAccount.source) + .with_for_update() + ): + runtime = current[account.source] + if ( + runtime.unavailable is None + and account.account_key == account_key(runtime) + and pending[account.source][2] == config_key(runtime) + and account.status != SourceStatus.AUTHENTICATION_FAILED.value + ): + pending[account.source] = ( + account.status or SourceStatus.UNAVAILABLE.value, + account.retry_at or now, + pending[account.source][2], + ) if ( await session.scalar(select(Series.id).where(Series.id == series_id).with_for_update()) is None diff --git a/src/pullbox/services/metadata_source_status.py b/src/pullbox/services/metadata_source_status.py new file mode 100644 index 00000000..978c12e1 --- /dev/null +++ b/src/pullbox/services/metadata_source_status.py @@ -0,0 +1,131 @@ +"""Read-only, bounded operator views of account holds and deferred series work.""" + +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models import MetadataSeriesRetry, Series +from pullbox.models.metadata_source_account import MetadataSourceAccount +from pullbox.schemas.metadata_sources import ( + DeferredMetadataRead, + SourceAccountRead, + SourceDescriptor, + SourceStatus, +) +from pullbox.schemas.pagination import PaginatedResponse +from pullbox.services.metadata_account_admission import account_key +from pullbox.services.metadata_discovery import describe_source_policies +from pullbox.services.metadata_series_retry import config_key +from pullbox.services.metadata_sources import SourceRuntime, load_source_runtime + + +async def _accounts( + session: AsyncSession, runtime: list[SourceRuntime] +) -> dict[str, SourceAccountRead]: + current = {item.policy.source.value: item for item in runtime} + result = {} + for row in await session.scalars(select(MetadataSourceAccount)): + source = current.get(row.source) + if ( + source is not None + and source.unavailable is None + and row.account_key == account_key(source) + ): + result[row.source] = SourceAccountRead( + status=row.status, retry_at=row.retry_at, probe_until=row.lease_until + ) + return result + + +async def source_status(session: AsyncSession, *, gcd_api_enabled: bool) -> list[SourceDescriptor]: + runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled) + accounts = await _accounts(session, runtime) + counts = { + row.source: (row.series_count, row.work_count) + for row in ( + await session.execute( + select( + MetadataSeriesRetry.source, + func.count(func.distinct(MetadataSeriesRetry.series_id)).label("series_count"), + func.count().label("work_count"), + ).group_by(MetadataSeriesRetry.source) + ) + ) + } + result = describe_source_policies( + [item.policy for item in runtime], gcd_api_enabled=gcd_api_enabled + ) + for item in result: + item.account = accounts.get(item.source.value) + item.deferred_series, item.deferred_work = counts.get(item.source.value, (0, 0)) + return result + + +async def deferred_work( + session: AsyncSession, + *, + source: MetadataSource | None, + limit: int, + offset: int, + gcd_api_enabled: bool, +) -> PaginatedResponse[DeferredMetadataRead]: + runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled) + accounts = await _accounts(session, runtime) + current = {item.policy.source.value: item for item in runtime} + criteria = (MetadataSeriesRetry.source == source.value,) if source is not None else () + total = ( + await session.scalar(select(func.count()).select_from(MetadataSeriesRetry).where(*criteria)) + or 0 + ) + rows = await session.execute( + select(MetadataSeriesRetry, Series.title) + .join(Series, Series.id == MetadataSeriesRetry.series_id) + .where(*criteria) + .order_by(MetadataSeriesRetry.series_id, MetadataSeriesRetry.id) + .limit(limit) + .offset(offset) + ) + now = datetime.now(UTC) + items = [] + for row, title in rows: + active = current.get(row.source) + account = accounts.get(row.source) + state: str = "ready" + due = row.retry_at + if active and active.unavailable is not None: + state, due = "source_disabled", None + elif account and account.status is SourceStatus.AUTHENTICATION_FAILED: + state, due = "authentication_required", None + elif ( + account + and account.status is not None + and any(value and value > now for value in (account.retry_at, account.probe_until)) + ): + state, due = ( + "waiting", + max(value for value in (account.retry_at, account.probe_until) if value), + ) + elif active is not None and config_key(active) != row.config_key: + due = now + elif row.retry_at is None: + state = "authentication_required" + elif row.retry_at > now: + state = "waiting" + items.append( + DeferredMetadataRead.model_validate( + dict( + id=row.id, + series_id=row.series_id, + series_title=title, + source=row.source, + task_id=row.task_id, + state=state, + retry_at=due, + ) + ) + ) + return PaginatedResponse( + items=items, total=total, limit=limit, offset=offset, has_more=offset + len(items) < total + ) diff --git a/src/pullbox/tasks/metadata_sweep_state.py b/src/pullbox/tasks/metadata_sweep_state.py index 7f45e441..d97800f3 100644 --- a/src/pullbox/tasks/metadata_sweep_state.py +++ b/src/pullbox/tasks/metadata_sweep_state.py @@ -88,10 +88,19 @@ def schedule_sweep(task_id: str, state: MetadataSweep) -> None: async def recover_metadata_sweep_schedules() -> None: """Restore interrupted batches without waiting for tomorrow's cron.""" + from pullbox.config import get_settings from pullbox.database import get_session_factory + from pullbox.services.metadata_series_retry import retry_deadline, retry_runtime async with get_session_factory()() as session: + runtime = await retry_runtime( + session, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled + ) for task_id in TASK_IDS: state = await load_sweep(session, task_id) + if not state.active: + deadline = await retry_deadline(session, task_id, runtime) + if deadline is not None: + state = MetadataSweep(active=True, retry_at=deadline.timestamp()) if state.active: schedule_sweep(task_id, state) diff --git a/src/pullbox/ui/settings_routes.py b/src/pullbox/ui/settings_routes.py index 472eebac..445ee041 100644 --- a/src/pullbox/ui/settings_routes.py +++ b/src/pullbox/ui/settings_routes.py @@ -280,13 +280,12 @@ async def load_settings_tab(request: Request, session: DbSession, tab: str) -> d active_key = await get_comicvine_api_key(session) ctx["has_comicvine_key"] = bool(active_key) ctx["obfuscated_key"] = obfuscate_api_key(active_key) - from pullbox.services.metadata_discovery import describe_source_policies - from pullbox.services.metadata_sources import read_source_policies + from pullbox.services.metadata_source_status import source_status ctx["metadata_source_seed"] = [ policy.model_dump(mode="json") - for policy in describe_source_policies( - await read_source_policies(session), + for policy in await source_status( + session, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled, ) ] diff --git a/src/pullbox/ui/static/js/metadata-sources.js b/src/pullbox/ui/static/js/metadata-sources.js index 57a62693..d1d397b7 100644 --- a/src/pullbox/ui/static/js/metadata-sources.js +++ b/src/pullbox/ui/static/js/metadata-sources.js @@ -12,6 +12,8 @@ function metadataSourceSettings(seed, csrf) { metronBase: null, metronEnabled: false, metronToken: '', metronClear: false, metronSaving: false, metronError: '', metronMessage: '', metronConflict: false, healthRefreshPending: false, + retryOpen: false, retrySource: null, retryPage: {items: [], total: 0, offset: 0, limit: 10, has_more: false}, + retryLoading: false, retryError: '', retryTrigger: null, init() { this.accept(seed); this.acceptMetron(seed.find(item => item.source === 'metron_api')); }, destroy() { this.alive = false; this.metronToken = ''; @@ -71,6 +73,7 @@ function metadataSourceSettings(seed, csrf) { this.savedSources = this.savedSources.map(item => item.source === 'metron_api' && item.revision === base.revision ? clone(descriptor) : item); this.acceptMetron(policy); + this.healthRefreshPending = true; this.healthMessage = ''; this.metronMessage = 'Metron settings saved. Connection checks use this saved configuration.'; } catch (_) { @@ -210,7 +213,7 @@ function metadataSourceSettings(seed, csrf) { }, status(item) { if (!item.capabilities.length && item.availability !== 'feature_disabled') return 'Not available in this build'; - const state = item.availability || item.last_status || 'not_checked'; + const state = item.availability || item.account?.status || item.last_status || 'not_checked'; return { ok: 'Ready', empty: 'Ready', disabled: 'Disabled', feature_disabled: 'Not released yet', not_implemented: 'Not available in this build', unconfigured: 'Not configured', @@ -220,6 +223,47 @@ function metadataSourceSettings(seed, csrf) { not_checked: 'Not checked yet', }[state] || 'Needs attention'; }, + dateLabel(value) { return value ? new Date(value).toLocaleString() : ''; }, + holdMessage(item) { + if (item.availability) return ''; + const account = item.account; + if (account?.probe_until && new Date(account.probe_until) > new Date()) return 'A connection check is in progress.'; + if (account?.status === 'authentication_failed') return 'Automatic requests are paused. Check saved access, then test the connection to resume waiting work.'; + if (account?.retry_at && new Date(account.retry_at) > new Date()) return 'Requests can resume after ' + this.dateLabel(account.retry_at) + '. Other sources can still run.'; + return ''; + }, + retryState(row) { + return { + ready: 'Ready for the next scheduled run', + waiting: 'Waiting until ' + this.dateLabel(row.retry_at), + authentication_required: 'Check saved access, then test the connection', + source_disabled: 'Enable and configure this source to resume', + }[row.state] || 'Needs attention'; + }, + async showRetries(source, trigger) { + if (this.retryLoading) return; + this.retrySource = source; this.retryTrigger = trigger; this.retryOpen = true; + this.retryPage = {items: [], total: 0, offset: 0, limit: 10, has_more: false}; + await this.loadRetries(0); + }, + closeRetries() { + if (this.retryLoading) return; + this.retryOpen = false; + this.$nextTick(() => this.retryTrigger?.focus({preventScroll: true})); + }, + async loadRetries(offset) { + if (this.retryLoading) return; + this.retryLoading = true; this.retryError = ''; + const query = new URLSearchParams({limit: '10', offset: String(offset)}); + if (this.retrySource) query.set('source', this.retrySource); + try { + const response = await this.request('/api/v1/metadata/retries?' + query); + if (!response.ok) throw new Error('load'); + if (this.alive) this.retryPage = response.data; + } catch (_) { + if (this.alive) this.retryError = 'Could not load deferred work. Check the connection and retry.'; + } finally { this.retryLoading = false; } + }, canTest(item) { return item.enabled && item.capabilities.length > 0 && !item.availability; }, async refreshHealth() { if (this.busy) { this.healthRefreshPending = true; return; } @@ -252,7 +296,12 @@ function metadataSourceSettings(seed, csrf) { } } catch (_) { if (this.alive) this.healthMessage = 'Could not check ' + this.labels[item.source] + '. Check the connection and retry.'; - } finally { this.testing = null; this.flushHealthRefresh(); } + } finally { + this.testing = null; + await this.refreshHealth(); + if (this.retryOpen && !this.retryLoading) await this.loadRetries(this.retryPage.offset); + this.flushHealthRefresh(); + } }, }; } diff --git a/src/pullbox/ui/templates/partials/settings_metadata_deferred.html b/src/pullbox/ui/templates/partials/settings_metadata_deferred.html new file mode 100644 index 00000000..a305fcee --- /dev/null +++ b/src/pullbox/ui/templates/partials/settings_metadata_deferred.html @@ -0,0 +1,24 @@ +
+
+

+ +
+

Series can have separate issue checks and metadata refreshes waiting. Files and reading progress are unchanged.

+

+ +
    + +
+

No deferred work on this page. Refresh the list or check an earlier page.

+ +
diff --git a/src/pullbox/ui/templates/partials/settings_metadata_sources.html b/src/pullbox/ui/templates/partials/settings_metadata_sources.html index 81717bf3..30e8d85c 100644 --- a/src/pullbox/ui/templates/partials/settings_metadata_sources.html +++ b/src/pullbox/ui/templates/partials/settings_metadata_sources.html @@ -59,14 +59,25 @@

+

+

+
+ +

+
+ + +
+ {% include "partials/settings_metadata_deferred.html" %} {% endcall %} {% include "partials/settings_metron.html" %} diff --git a/tests/api/test_metadata_recovery_api.py b/tests/api/test_metadata_recovery_api.py new file mode 100644 index 00000000..4e021c4c --- /dev/null +++ b/tests/api/test_metadata_recovery_api.py @@ -0,0 +1,134 @@ +"""Operators can inspect durable work and deliberately retry saved authentication.""" + +import os +import sys +from datetime import UTC, datetime + +import httpx +from sqlalchemy import select + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.models import MetadataSeriesRetry, Series +from pullbox.models.metadata_source_account import MetadataSourceAccount as Account +from pullbox.services.metadata_account_admission import account_key +from pullbox.services.metadata_series_retry import config_key +from pullbox.services.metadata_sources import load_source_runtime +from tests.api.test_metadata_sources_api import csrf, policy + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +pytest_plugins = ["conftest_security"] + + +async def seed(client, factory): + result = await client.put( + "/api/v1/metadata/sources/metron_api", + json=policy(credential="recovery-api-test-token"), + headers=csrf(client), + ) + assert result.status_code == 200 + async with factory.begin() as session: + runtime = next( + item + for item in await load_source_runtime(session, gcd_api_enabled=False) + if item.policy.source is MetadataSource.METRON_API + ) + session.add( + Account( + source="metron_api", + account_key=account_key(runtime), + status="authentication_failed", + ) + ) + for index in range(13): + series = Series(title=f"Waiting {index:02}", sort_title=f"Waiting {index:02}") + session.add(series) + await session.flush() + for task in ("refresh_metadata", "sync_new_issues"): + session.add( + MetadataSeriesRetry( + task_id=task, + series_id=series.id, + source="metron_api", + config_key=config_key(runtime), + status="authentication_failed", + ) + ) + + +async def test_status_exposes_current_hold_and_distinct_series_not_just_last_test( + authenticated_client, sec_db +): + await seed(authenticated_client, sec_db) + result = await authenticated_client.get("/api/v1/metadata/sources") + source = next(item for item in result.json() if item["source"] == "metron_api") + assert source.get("account", {}).get("status") == "authentication_failed", ( + "Settings must show the live hold even without a manual health check" + ) + assert source["deferred_series"] == 13 and source["deferred_work"] == 26 + assert "recovery-api-test-token" not in result.text and "account_key" not in result.text + page = await authenticated_client.get("/settings?tab=metadata") + assert '"deferred_series": 13' in page.text + + +async def test_deferred_work_is_paginated_and_read_only(authenticated_client, sec_db): + await seed(authenticated_client, sec_db) + url = "/api/v1/metadata/retries?source=metron_api&limit=10" + first = await authenticated_client.get(url) + assert first.status_code == 200 + data = first.json() + assert data["total"] == 26 and data["has_more"] and len(data["items"]) == 10 + assert data["items"][0]["series_title"] == "Waiting 00" + assert data["items"][0]["state"] == "authentication_required" + second = await authenticated_client.get(url + "&offset=10") + assert {row["id"] for row in data["items"]}.isdisjoint( + row["id"] for row in second.json()["items"] + ) + assert "config_key" not in first.text and "recovery-api-test-token" not in first.text + assert (await authenticated_client.get(url.replace("limit=10", "limit=101"))).status_code == 422 + async with sec_db() as session: + assert (await session.scalar(select(Account))).status == "authentication_failed" + + +async def test_retry_list_requires_interactive_operator(unauthenticated_client, sec_api_key): + url = "/api/v1/metadata/retries" + assert (await unauthenticated_client.get(url)).status_code in {401, 403} + assert ( + await unauthenticated_client.get(url, headers={"X-API-Key": sec_api_key}) + ).status_code in {401, 403} + + +async def test_test_connection_explicitly_recovers_authentication_and_wakes_series( + authenticated_client, sec_db, monkeypatch +): + from pullbox.core.provider_cooldown import ProviderCooldown + from pullbox.providers.metadata import sources + from tests.unit.test_metron_source import envelope, series_row + + await seed(authenticated_client, sec_db) + calls = [] + + def handle(request): + calls.append(request.url.path) + return httpx.Response(200, json=envelope([series_row()])) + + adapter = sources.MetronSource + monkeypatch.setattr( + sources, + "MetronSource", + lambda token: adapter( + token, + transport=httpx.MockTransport(handle), + cooldown=ProviderCooldown(), + minimum_interval=0, + ), + ) + response = await authenticated_client.post( + "/api/v1/metadata/sources/metron_api/test", headers=csrf(authenticated_client) + ) + assert response.status_code == 200 and response.json()["outcome"]["status"] == "ok" + assert len(calls) == 1 + async with sec_db() as session: + rows = list(await session.scalars(select(MetadataSeriesRetry))) + assert len(rows) == 26 and all(row.retry_at <= datetime.now(UTC) for row in rows) + status = await authenticated_client.get("/api/v1/metadata/retries?source=metron_api") + assert all(row["state"] == "ready" for row in status.json()["items"]) diff --git a/tests/e2e/test_metadata_sources_settings.py b/tests/e2e/test_metadata_sources_settings.py index 6aa549d0..d5b28816 100644 --- a/tests/e2e/test_metadata_sources_settings.py +++ b/tests/e2e/test_metadata_sources_settings.py @@ -9,6 +9,93 @@ pytestmark = pytest.mark.e2e +@pytest.mark.parametrize("theme,width", [("light", 1280), ("dark", 320)]) +def test_account_hold_and_deferred_work_keep_draft_and_allow_paged_review( + authed_page, seeded_server, theme, width +): + page = authed_page + page.set_viewport_size({"width": width, "height": 1000}) + SettingsPage(page, seeded_server).goto("metadata") + page.evaluate("theme => document.documentElement.dataset.theme = theme", theme) + sources = page.request.get(seeded_server + "/api/v1/metadata/sources").json() + for item in sources: + if item["source"] == "metron_api": + item.update( + enabled=True, + availability=None, + credential_configured=True, + account={"status": "authentication_failed", "retry_at": None, "probe_until": None}, + deferred_series=13, + deferred_work=26, + ) + page.route("**/api/v1/metadata/sources", lambda route: route.fulfill(json=sources)) + page.evaluate("window.dispatchEvent(new CustomEvent('metadata-credentials-updated'))") + card = page.get_by_test_id("metadata-source-priority") + order = card.get_by_test_id("metadata-order-global") + expect(card.get_by_role("button", name="Test Metron", exact=True)).to_be_enabled() + order.locator('[data-order-direction="down"]').first.click() + draft = order.locator("[data-source-label]").all_text_contents() + + def retries(route): + from urllib.parse import parse_qs, urlparse + + offset = int(parse_qs(urlparse(route.request.url).query).get("offset", [0])[0]) + route.fulfill( + json={ + "total": 26, + "limit": 10, + "offset": offset, + "has_more": offset < 20, + "items": [ + { + "id": index, + "series_id": 1, + "series_title": f"Waiting series {index}", + "source": "metron_api", + "task_id": "refresh_metadata", + "state": "authentication_required", + "retry_at": None, + } + for index in range(offset + 1, min(offset + 11, 27)) + ], + } + ) + + page.route("**/api/v1/metadata/retries?**", retries) + button = card.get_by_role("button", name="View deferred work for Metron", exact=True) + expect(card.get_by_text("13 series have deferred metadata work.", exact=True)).to_be_visible() + button.press("Enter") + panel = page.get_by_test_id("metadata-deferred-work") + expect(panel.get_by_text("Waiting series 1", exact=True)).to_be_visible() + panel.get_by_role("button", name="Next", exact=True).press("Enter") + expect(panel.get_by_text("Waiting series 11", exact=True)).to_be_visible() + expect(panel.get_by_text("Waiting series 1", exact=True)).to_have_count(0) + expect(order.locator("[data-source-label]")).to_have_text(draft) + expect(card.get_by_role("button", name="Save metadata priority", exact=True)).to_be_enabled() + assert panel.evaluate("node => node.scrollWidth <= node.clientWidth + 1") + assert_no_axe_violations( + page, + name=f"metadata-deferred-{theme}-{width}", + include=["[data-testid='metadata-source-health']"], + ) + panel.evaluate("node => node.scrollIntoView({block: 'start'})") + page.screenshot( + path=f"test-results/metadata-deferred-{theme}-{width}.png", animations="disabled" + ) + panel.evaluate("node => { node.keepThisPanel = true; }") + page.route( + "**/api/v1/metadata/retries?**", + lambda route: route.fulfill(status=503, json={"detail": "Unavailable"}), + ) + panel.get_by_role("button", name="Next", exact=True).press("Enter") + expect(panel.get_by_role("alert")).to_contain_text("Could not load deferred work") + expect(panel.get_by_text("Waiting series 11", exact=True)).to_be_visible() + assert panel.evaluate("node => node.keepThisPanel") is True + panel.get_by_role("button", name="Close deferred work", exact=True).press("Enter") + expect(panel).to_be_hidden() + expect(button).to_be_focused() + + def test_metadata_priorities_save_reload_and_domain_reset(authed_page, seeded_server): page = authed_page errors = [] diff --git a/tests/integration/metadata_identity/test_account_recovery.py b/tests/integration/metadata_identity/test_account_recovery.py new file mode 100644 index 00000000..8f2d95ac --- /dev/null +++ b/tests/integration/metadata_identity/test_account_recovery.py @@ -0,0 +1,344 @@ +"""Explicit authentication recovery preserves holds and wakes deferred work safely.""" + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select, update + +from pullbox.models import MetadataSeriesRetry, Series +from pullbox.models.metadata_source_account import MetadataSourceAccount as Account +from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.services.metadata_series_retry import admit_retry, retry_candidates, settle_retry +from tests.integration.metadata_identity.test_source_account_admission import ( # noqa: F401 + accounts as account_setup, +) +from tests.integration.metadata_identity.test_source_account_admission import ( + configured_sources, # noqa: F401 + reader, +) +from tests.unit.test_metadata_source_reads import ReadAdapter + + +@pytest.fixture(name="accounts") +async def recovery_accounts(account_setup): # noqa: F811 - imported fixture + return account_setup + + +async def held(factory): + adapter = ReadAdapter(error=MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED)) + registry = await reader(factory, adapter) + async with factory.begin() as session: + series = Series(title="Waiting series", sort_title="Waiting series") + session.add(series) + await session.flush() + identifier = series.id + admission = await admit_retry( + session, "refresh_metadata", identifier, gcd_api_enabled=False, retry_only=False + ) + result = await registry.series(adapter.source, "42") + async with factory.begin() as session: + await settle_retry( + session, + "refresh_metadata", + identifier, + admission, + outcomes=(SourceOutcome(source=adapter.source, status=result.status),), + ) + adapter.error = None + return adapter, identifier + + +async def test_manual_probe_reopens_same_account_and_wakes_deferred_series_after_restart(accounts): + engine, factory = accounts + adapter, identifier = await held(factory) + registry = await reader(factory, adapter) + assert (await registry.check(adapter.source)).status is SourceStatus.AUTHENTICATION_FAILED + assert ( + await registry.check(adapter.source, retry_authentication=True) + ).status is SourceStatus.OK + await engine.dispose() + async with factory() as session: + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.status == "unavailable" and row.retry_at <= datetime.now(UTC) + assert row.revision == 2 + runtime = list((await reader(factory, adapter)).runtime.values()) + assert await retry_candidates(session, "refresh_metadata", runtime, limit=5) == [identifier] + admission = await admit_retry( + session, "refresh_metadata", identifier, gcd_api_enabled=False, retry_only=True + ) + assert not admission.held and adapter.source in admission.registry.runtime + + +async def test_only_one_manual_auth_probe_runs_and_cancellation_keeps_hold(accounts): + _, factory = accounts + adapter, _ = await held(factory) + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def probe(): + calls.append(1) + entered.set() + await release.wait() + + adapter.check = probe + registry = await reader(factory, adapter) + task = asyncio.create_task(registry.check(adapter.source, retry_authentication=True)) + try: + async with asyncio.timeout(3): + await entered.wait() + result = await (await reader(factory, adapter)).check( + adapter.source, retry_authentication=True + ) + assert result.status is SourceStatus.AUTHENTICATION_FAILED + assert 0 < result.retry_after_seconds <= 45 + assert calls == [1] + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + async with factory() as session: + account = await session.scalar(select(Account)) + assert account.status == "authentication_failed" and account.lease_until is None + assert (await session.scalar(select(MetadataSeriesRetry))).retry_at is None + release.set() + assert ( + await registry.check(adapter.source, retry_authentication=True) + ).status is SourceStatus.OK + + +async def test_explicit_probe_does_not_bypass_a_timed_cooldown(accounts): + _, factory = accounts + adapter = ReadAdapter(error=MetadataSourceError(SourceStatus.RATE_LIMITED, 720)) + registry = await reader(factory, adapter) + await registry.series(adapter.source, "42") + + async def forbidden(): + pytest.fail("Manual checks cannot bypass provider rate limits") + + adapter.check = forbidden + result = await registry.check(adapter.source, retry_authentication=True) + assert result.status is SourceStatus.RATE_LIMITED and 0 < result.retry_after_seconds <= 720 + + +@pytest.mark.parametrize( + "status", [SourceStatus.AUTHENTICATION_FAILED, SourceStatus.INCOMPATIBLE_RESPONSE] +) +async def test_failed_manual_check_cannot_wake_or_clear_auth_held_work(accounts, status): + _, factory = accounts + adapter, _ = await held(factory) + adapter.error = MetadataSourceError(status) + result = await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + assert result.status is status + async with factory() as session: + assert (await session.scalar(select(Account))).status == "authentication_failed" + assert (await session.scalar(select(MetadataSeriesRetry))).retry_at is None + + +async def test_late_failure_settlement_does_not_strand_work_after_successful_probe(accounts): + _, factory = accounts + adapter, identifier = await held(factory) + async with factory.begin() as session: + other = Series(title="Late series", sort_title="Late series") + session.add(other) + await session.flush() + identifier = other.id + admission = await admit_retry( + session, "refresh_metadata", identifier, gcd_api_enabled=False, retry_only=False + ) + assert ( + await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + ).status is SourceStatus.OK + async with factory.begin() as session: + await settle_retry( + session, + "refresh_metadata", + identifier, + admission, + outcomes=( + SourceOutcome(source=adapter.source, status=SourceStatus.AUTHENTICATION_FAILED), + ), + ) + async with factory() as session: + row = await session.scalar( + select(MetadataSeriesRetry).where(MetadataSeriesRetry.series_id == identifier) + ) + assert row.retry_at is not None and row.retry_at <= datetime.now(UTC), ( + "A stale failure must remain retryable after recovery" + ) + + +async def test_manual_probe_does_not_wake_unrelated_or_newer_retry_scopes(accounts): + _, factory = accounts + adapter, _ = await held(factory) + async with factory.begin() as session: + await session.execute(update(MetadataSeriesRetry).values(config_key="different-generation")) + await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + async with factory() as session: + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.status == "authentication_failed" and row.retry_at is None + + +async def test_abandoned_auth_probe_expires_but_automatic_reads_stay_held(accounts): + _, factory = accounts + adapter, _ = await held(factory) + runtime = (await reader(factory, adapter)).runtime[adapter.source] + permit = await runtime.account_admission.admit(runtime, retry_authentication=True) + assert permit.probe + async with factory.begin() as session: + await session.execute( + update(Account).values(lease_until=datetime.now(UTC) - timedelta(seconds=1)) + ) + registry = await reader(factory, adapter) + assert ( + await registry.series(adapter.source, "42") + ).status is SourceStatus.AUTHENTICATION_FAILED + assert ( + await registry.check(adapter.source, retry_authentication=True) + ).status is SourceStatus.OK + + +@pytest.mark.parametrize( + "status", [SourceStatus.RATE_LIMITED, SourceStatus.TIMEOUT, SourceStatus.UNAVAILABLE] +) +async def test_transient_probe_failure_schedules_held_work_without_immediate_retry( + accounts, status +): + _, factory = accounts + adapter, _ = await held(factory) + adapter.error = MetadataSourceError(status, 720) + assert ( + await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + ).status is status + async with factory() as session: + account = await session.scalar(select(Account)) + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.status == status.value and row.retry_at == account.retry_at + assert datetime.now(UTC) < row.retry_at <= datetime.now(UTC) + timedelta(seconds=720) + + +async def test_stale_manual_probe_cannot_wake_work_after_newer_failure(accounts): + _, factory = accounts + adapter, _ = await held(factory) + runtime = (await reader(factory, adapter)).runtime[adapter.source] + attempt = await runtime.account_admission.admit(runtime, retry_authentication=True) + attempt.started = True + attempt.outcome = SourceOutcome(source=adapter.source, status=SourceStatus.OK) + async with factory.begin() as session: + await session.execute( + update(Account).values(revision=Account.revision + 1, lease_until=None) + ) + await runtime.account_admission.finish(runtime, attempt) + async with factory() as session: + assert (await session.scalar(select(Account))).status == "authentication_failed" + assert (await session.scalar(select(MetadataSeriesRetry))).retry_at is None + + +async def test_auth_probe_migration_roundtrip_preserves_holds_and_retry_work(accounts): + from alembic.autogenerate import compare_metadata + from alembic.migration import MigrationContext + from sqlalchemy import MetaData + + from pullbox.models import Base + from tests.integration.metadata_identity.test_production_migration import _revision + + engine, factory = accounts + adapter, identifier = await held(factory) + runtime = (await reader(factory, adapter)).runtime[adapter.source] + permit = await runtime.account_admission.admit(runtime, retry_authentication=True) + assert permit.probe + async with engine.begin() as connection: + + def migrate(sync): + revision = _revision("x5r6s7t8u901_allow_metadata_authentication_probes", sync) + revision.downgrade() + revision.upgrade() + expected = MetaData() + name = "metadata_source_accounts" + Base.metadata.tables[name].to_metadata(expected) + context = MigrationContext.configure( + sync, + opts={ + "include_object": lambda obj, item, type_, reflected, compare_to: ( + item == name if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + assert compare_metadata(context, expected) == [] + + await connection.run_sync(migrate) + async with factory() as session: + row = await session.scalar(select(Account)) + assert row.status == "authentication_failed" and row.lease_until is None + assert row.revision > permit.revision + assert (await session.scalar(select(MetadataSeriesRetry))).series_id == identifier + + +async def test_status_reads_are_bounded_read_only_and_hide_replaced_account(accounts): + from sqlalchemy import event + + from pullbox.core.encryption import encrypt_secret + from pullbox.models.metadata_source import MetadataSourceConfig + from pullbox.services.metadata_source_status import deferred_work, source_status + + engine, factory = accounts + adapter, _ = await held(factory) + statements = [] + + def record(*args): + statements.append(args[2]) + + event.listen(engine.sync_engine, "before_cursor_execute", record) + try: + async with factory() as session: + result = await source_status(session, gcd_api_enabled=False) + metron = next(item for item in result if item.source is adapter.source) + assert metron.deferred_series == metron.deferred_work == 1 + assert metron.account.status is SourceStatus.AUTHENTICATION_FAILED + await session.rollback() + finally: + event.remove(engine.sync_engine, "before_cursor_execute", record) + assert len(statements) <= 10 and all(item.startswith("SELECT") for item in statements) + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == adapter.source.value) + .values(credential_secret=encrypt_secret("different-test-account")) + ) + async with factory() as session: + result = await source_status(session, gcd_api_enabled=False) + assert next(item for item in result if item.source is adapter.source).account is None + assert ( + await deferred_work( + session, source=adapter.source, limit=10, offset=0, gcd_api_enabled=False + ) + ).items[0].state == "ready" + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == adapter.source.value) + .values(enabled=False) + ) + async with factory() as session: + assert ( + await deferred_work( + session, source=adapter.source, limit=10, offset=0, gcd_api_enabled=False + ) + ).items[0].state == "source_disabled" + + +async def test_manual_probe_recovers_auth_retries_created_before_account_state(accounts): + from sqlalchemy import delete + + _, factory = accounts + adapter, _ = await held(factory) + async with factory.begin() as session: + await session.execute(delete(Account)) + result = await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + assert result.status is SourceStatus.OK + async with factory() as session: + row = await session.scalar(select(MetadataSeriesRetry)) + assert row.retry_at is not None, ( + "An explicit successful check must recover pre-account held work" + ) diff --git a/tests/integration/metadata_identity/test_isolated_metadata_retry.py b/tests/integration/metadata_identity/test_isolated_metadata_retry.py index aaa4a42d..ee2ffc42 100644 --- a/tests/integration/metadata_identity/test_isolated_metadata_retry.py +++ b/tests/integration/metadata_identity/test_isolated_metadata_retry.py @@ -145,6 +145,94 @@ async def rejected(identifier, **kwargs): assert list(await session.scalars(select(MetadataSeriesRetry))) == [] +@pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) +async def test_successful_explicit_probe_retries_held_task_without_reimport(isolated, task_id): + from pullbox.models import Issue + from pullbox.models.issue import IssueStatus + from tests.integration.metadata_identity.test_source_account_admission import reader + + factory, adapter, scheduler = isolated + series_id, data = await add_series(factory) + offer(adapter, data) + original = adapter.series + + async def rejected(*args, **kwargs): + raise MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED) + + adapter.series = rejected + await getattr(metadata_task, task_id)() + scheduler._scheduler.add_job.assert_not_called() + adapter.series = original + scheduler.schedule_task_continuation.reset_mock() + result = await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + assert result.status is SourceStatus.OK + assert scheduler.schedule_task_continuation.called, ( + "Recovered work must not wait for the next daily cron" + ) + assert scheduler.schedule_task_continuation.call_args.args == (task_id,) + assert scheduler.schedule_task_continuation.call_args.kwargs["interval_seconds"] == 60 + assert scheduler.schedule_task_continuation.call_args.kwargs["run_at"] <= datetime.now( + UTC + ) + timedelta(seconds=61) + scheduler.schedule_task_continuation.reset_mock() + await factory.kw["bind"].dispose() + from unittest.mock import patch + + from pullbox.tasks import metadata_sweep_state + + with patch("pullbox.database.get_session_factory", return_value=factory): + await metadata_sweep_state.recover_metadata_sweep_schedules() + assert scheduler.schedule_task_continuation.call_args.args == (task_id,) + assert (await getattr(metadata_task, task_id)()).status == "completed" + async with factory() as session: + series = await session.get(Series, series_id) + assert series.issue_count == 2 and series.path == "/read-only/original" + owned = await session.scalar( + select(Issue).where(Issue.series_id == series_id, Issue.issue_number_text == "1") + ) + assert owned.status is IssueStatus.OWNED and owned.manual_skip + assert list(await session.scalars(select(MetadataSeriesRetry))) == [] + assert scheduler._scheduler.add_job.call_count == 1 + + +async def test_recovery_continuation_does_not_restart_completed_daily_sweep(isolated, monkeypatch): + from pullbox.core.metadata_identity import MetadataSource + from pullbox.providers.metadata import sources + from tests.integration.metadata_identity.test_source_account_admission import reader + + factory, adapter, _ = isolated + _, data = await add_series(factory) + offer(adapter, data) + original = adapter.series + + async def rejected(*args, **kwargs): + raise MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED) + + adapter.series = rejected + async with factory.begin() as session: + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == Source.COMICVINE_LOCAL.value) + .values(enabled=True) + ) + _, local_data = await add_series(factory, identifier=43, source=MetadataSource.COMICVINE_LOCAL) + local = DailyAdapter(local_data) + local.source = Source.COMICVINE_LOCAL + offer(local, local_data) + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: {**registry(adapter).factories, **registry(local).factories}, + ) + await metadata_task.sync_new_issues() + assert local.calls + prior = list(local.calls) + adapter.series = original + await (await reader(factory, adapter)).check(adapter.source, retry_authentication=True) + await metadata_task.sync_new_issues() + assert local.calls == prior, "Account recovery must not rerun completed healthy-source work" + + @pytest.mark.parametrize("task_id", ["sync_new_issues", "refresh_metadata"]) async def test_successful_fallback_retries_only_the_failed_source(isolated, monkeypatch, task_id): from pullbox.core.comicvine_key import save_comicvine_api_key diff --git a/tests/integration/metadata_identity/test_source_account_admission.py b/tests/integration/metadata_identity/test_source_account_admission.py index e0bb3eda..01775586 100644 --- a/tests/integration/metadata_identity/test_source_account_admission.py +++ b/tests/integration/metadata_identity/test_source_account_admission.py @@ -190,9 +190,9 @@ async def test_probe_is_not_claimed_while_waiting_for_local_request_capacity(acc original = gate.admit entered = asyncio.Event() - async def admission(runtime): + async def admission(runtime, **kwargs): entered.set() - return await original(runtime) + return await original(runtime, **kwargs) monkeypatch.setattr(gate, "admit", admission) task = asyncio.create_task(instance.series(adapter.source, "42")) @@ -423,6 +423,7 @@ def migrate(sync): revision = _revision("w4q5r6s7t890_add_metadata_source_accounts", sync) revision.downgrade() revision.upgrade() + _revision("x5r6s7t8u901_allow_metadata_authentication_probes", sync).upgrade() expected = MetaData() name = "metadata_source_accounts" Base.metadata.tables[name].to_metadata(expected) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 2a209af3..ad006d3c 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["w4q5r6s7t890"] + assert script.get_heads() == ["x5r6s7t8u901"] + assert script.get_revision("x5r6s7t8u901").down_revision == "w4q5r6s7t890" assert script.get_revision("w4q5r6s7t890").down_revision == "v3p4q5r6s789" assert script.get_revision("v3p4q5r6s789").down_revision == "u2o3p4q5r678" assert script.get_revision("u2o3p4q5r678").down_revision == "t1n2o3p4q567" From 6b8b05640188cecdad25fc5219293b1776046c45 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 17:51:25 -0700 Subject: [PATCH 54/97] feat(metadata): persist canonical story arc metadata --- docs/development/ARCHITECTURE_OVERVIEW.md | 42 ++- docs/development/DATABASE_STANDARDS.md | 16 + .../services/metadata_arc_baselines.py | 223 +++++++++++++ src/pullbox/services/story_arc_catalog.py | 21 +- .../services/story_arc_catalog_persistence.py | 7 + src/pullbox/tasks/story_arc_metadata_task.py | 1 + .../test_arc_metadata_baselines.py | 308 ++++++++++++++++++ .../test_source_arc_commands.py | 8 + .../test_source_arc_monitoring.py | 30 ++ 9 files changed, 645 insertions(+), 11 deletions(-) create mode 100644 src/pullbox/services/metadata_arc_baselines.py create mode 100644 tests/integration/metadata_identity/test_arc_metadata_baselines.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index ebd4e53d..1d3f4f3a 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -411,12 +411,24 @@ Source-aware Add Series saves series/issue baselines in its catalog transaction, including final lifecycle/format inference. Derived values have a distinct origin, not provider attribution or user-override markers. Explicit refresh may replace those values; background enrichment still fills gaps only. Existing-owner Add -does not replace its baseline or local edits. Ordinary refresh command/task wiring, -the remaining credits/membership fields and coordinated XML/sidecar writers are -still pending; durable baselines alone do not complete those workflows. - -The existing import/cache and ordinary series-refresh consumers have not yet -migrated to this registry. Metron has token settings, bounded transport and +does not replace its baseline or local edits. Manual and scheduled Series refresh +use the revision-checked canonical/catalog writer. Source-bound Story Arc Add also +persists arc provenance and baselines for newly seeded parents/issues in its +existing atomic graph transaction. Reused members retain their metadata and history. +Arc refresh reads current descriptive values under the claimed arc revision, +preserves edits and intentional clears, and saves the new baseline atomically with +membership changes. Scheduled enrichment fills gaps; explicit refresh may replace +provider-managed values. Managed placements keep the arc name fixed without +blocking other safe changes; the baseline records that restriction separately +from user overrides. Existing order, skipped and removed members, partial parent +catalogs and pending-placement review remain unchanged. Legacy snapshots without +normalized source evidence are not silently assigned canonical provenance. +Multi-source Arc descriptive-read integration, remaining credits/membership fields +and coordinated XML/sidecar writers are still pending; baselines alone do not +complete those workflows. + +The existing import/cache consumers have not yet migrated to this registry. +Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and GCD API v2 is additionally disabled by the release feature flag. Capability lists describe implemented adapter operations, not planned features. @@ -696,8 +708,10 @@ windows where a valid source checkpoint permits them. Native and local sources do not require a ComicVine API key. Failed sources retain their checkpoints, including when a fallback succeeds. Retry attempts restrict reads to deferred sources and commit retry settlement with library/cursor changes. Authentication -failures have no timed retry: a changed source policy or credential scope admits -them again. Only one-way configuration scope keys are stored, never credentials. +failures have no timed retry. Changed entity configuration can make an entity +retry eligible, but the account guard still blocks known-failing credentials; +priority edits cannot bypass an authentication hold. Only one-way configuration +scope keys are stored, never credentials. Cancellation, failed commits and stale attempts cannot discard or resurrect settled retry work. Infrastructure lock failures retain a sweep-level pause. Account-wide cooldown admission is separate from entity retries. The source @@ -711,8 +725,16 @@ seconds. Cancellation releases its lease without clearing the previous failure. Revision-checked outcome writes cannot clear a newer hold. Account transactions are short and independent of library transactions; provider I/O starts only after the admission transaction ends. Existing client request pacing remains -in force. Authentication-held work still requires corrected credentials; operator -retry visibility and deliberate re-probe controls remain separate follow-up work. +in force. Settings shows current credential-scoped holds and a bounded, paginated +list of deferred series work without provider calls or automatic polling. An +authenticated, CSRF-protected Test connection explicitly permits one bounded +authentication probe, never bypassing a timed rate limit. Success makes matching +held retries due in the same transaction as account recovery; failed/cancelled or +stale probes cannot clear newer holds. Older held retries without an account row +are handled by the same manual check. Late authentication results are reconciled +against recovered account state rather than recreating an indefinite hold. +Successful recovery schedules existing continuations after commit, and startup +also restores eligible retries for previously inactive sweeps. Post-restore aftercare keeps its recovery marker while continuation batches remain active; it observes completion without retaining a database transaction between checks. Cancellation leaves the marker and sweep checkpoint available for the next startup. diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index f4fda772..f34e6ac1 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -424,6 +424,15 @@ Cancellation releases the probe while retaining the prior failure; a crashed process recovers through lease expiry. Reads fail closed if admission storage cannot be checked, instead of repeatedly submitting known-failing requests. +An explicit Test connection may lease an authentication-held account while its +retry deadline remains null. Only success clears that hold; cancellation retains +it. Account recovery and matching credential/configuration-scoped entity retry +deadlines commit atomically, with revision increments invalidating stale work. +Scheduler wakeups happen after commit. Downgrading the authentication-probe +migration clears active authentication leases and advances their revisions without +discarding the hold, library or entity retries. Operator read DTOs expose status, +deadlines and bounded task counts, never account/configuration keys or credentials. + ### Canonical Metadata Baselines Series, issue and Story Arc metadata baselines use separate real foreign keys, @@ -441,6 +450,13 @@ catches the conflict. Future refresh writers must additionally revalidate their captured source-policy and ownership revisions and current user values before applying network results; the baseline service is not a substitute for that check. +Source-bound Story Arc commands save their canonical arc baseline inside the +existing revision-claimed catalog savepoint. Newly seeded parent/issue baselines +share that transaction; pre-existing rows are not retrospectively attributed to +the incoming source. Issue baseline writes are batched at 200. Refresh reloads +current mapped fields after claiming the arc, retaining user changes and clears. +Corrupt provenance aborts the entire refresh, including membership additions. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/services/metadata_arc_baselines.py b/src/pullbox/services/metadata_arc_baselines.py new file mode 100644 index 00000000..7bb67d55 --- /dev/null +++ b/src/pullbox/services/metadata_arc_baselines.py @@ -0,0 +1,223 @@ +"""Canonical provenance inside the existing Story Arc catalog transaction.""" + +from collections.abc import Sequence +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.core.story_arc_identity import normalize_story_arc_name +from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity +from pullbox.models.publisher import Publisher +from pullbox.schemas.metadata_snapshot import ( + FieldOrigin, + MetadataValues, + field_domain, +) +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import ( + MetadataBaselineWrite, + load_metadata_baseline, + save_metadata_baselines, +) +from pullbox.services.metadata_series_adoption import persist_adoption_series_baseline +from pullbox.services.metadata_sources import read_source_policies +from pullbox.services.story_arc_catalog_types import StoryArcCatalogPreview +from pullbox.services.story_arc_service import StoryArcService + + +async def persist_arc_metadata( + session: AsyncSession, + arc: StoryArc, + preview: StoryArcCatalogPreview, + *, + created: bool, + replace_managed: bool = False, +) -> None: + """Apply descriptive fields only; membership and placements retain their own rules.""" + evidence = preview.source_evidence + if evidence is None: + return + kind = MetadataEntityKind.STORY_ARC + saved = await load_metadata_baseline(session, kind, arc.id) + identities = tuple( + ExternalIdentityRef(IdentityNamespace(row.source), kind, row.external_id) + for row in await session.scalars( + select(StoryArcExternalIdentity).where( + StoryArcExternalIdentity.story_arc_id == arc.id, + StoryArcExternalIdentity.namespace == "story_arc", + StoryArcExternalIdentity.source.in_([item.value for item in IdentityNamespace]), + StoryArcExternalIdentity.verification_state == IdentityVerificationState.VERIFIED, + ) + ) + ) + current = None + if not created: + # The caller has claimed the arc revision. Discard a stale identity-map + # view before comparing user edits with the last committed baseline. + await session.refresh(arc, ["name", "description", "publisher_id", "cover_url"]) + values = saved.snapshot.values.model_dump() if saved else {} + values.update( + title=arc.name, + description=arc.description, + image_url=arc.cover_url, + publisher=await session.scalar( + select(Publisher.name).where(Publisher.id == arc.publisher_id) + ), + ) + current = MetadataValues.model_validate(values) + now = datetime.now(UTC) + retain_name = not created and await StoryArcService._has_managed_placements( + session, story_arc_id=arc.id + ) + snapshot = assemble_metadata( + kind, + identities, + [evidence.arc], + await read_source_policies(session), + now=now, + current=current, + previous=saved.snapshot if saved else None, + replace_managed=replace_managed, + fields=frozenset(MetadataValues.model_fields) - {"title"} if retain_name else None, + ) + if retain_name and evidence.arc.title.strip() != arc.name: + snapshot = snapshot.model_copy( + update={"diagnostics": (*snapshot.diagnostics, "title_retained_for_managed_placements")} + ) + title = (snapshot.values.title or "").strip() + if not title: + raise ValueError("Story arc metadata requires a title") + if title != snapshot.values.title: + snapshot = snapshot.model_copy( + update={ + "values": snapshot.values.model_copy(update={"title": title}), + "origins": ( + *(item for item in snapshot.origins if item.field != "title"), + FieldOrigin( + field="title", + domain=field_domain(kind, "title"), + observed_at=now, + derivation="normalization", + ), + ), + } + ) + arc.name = title + arc.normalized_name = normalize_story_arc_name(title) + arc.description = snapshot.values.description + arc.cover_url = snapshot.values.image_url + # Reuse the catalog's publisher creation and caller-owned transaction. + from pullbox.services.story_arc_catalog_persistence import publisher_id + + arc.publisher_id = await publisher_id(session, snapshot.values.publisher) + await save_metadata_baselines( + session, [MetadataBaselineWrite(arc.id, snapshot, saved.revision if saved else 0)] + ) + + +async def persist_seeded_metadata( + session: AsyncSession, + preview: StoryArcCatalogPreview, + parents: Sequence[Series], + issues: Sequence[Issue], +) -> None: + """Only newly seeded rows receive provider baselines; Add is not Refresh.""" + evidence = preview.source_evidence + if evidence is None or not (parents or issues): + return + policies = await read_source_policies(session) + now = datetime.now(UTC) + parent_profiles = {row.external_id: row for row in evidence.series} + issue_profiles = {row.external_id: row for row in evidence.issues} + # Caller-created rows have just acquired their one native provider identity. + from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity + + parent_keys = ( + dict( + ( + await session.execute( + select( + SeriesExternalIdentity.series_id, SeriesExternalIdentity.external_id + ).where( + SeriesExternalIdentity.series_id.in_([row.id for row in parents]), + SeriesExternalIdentity.identity_namespace + == preview.source.identity_namespace, + ) + ) + ) + .tuples() + .all() + ) + if parents + else {} + ) + issue_keys = ( + dict( + ( + await session.execute( + select(IssueExternalIdentity.issue_id, IssueExternalIdentity.external_id).where( + IssueExternalIdentity.issue_id.in_([row.id for row in issues]), + IssueExternalIdentity.identity_namespace + == preview.source.identity_namespace, + ) + ) + ) + .tuples() + .all() + ) + if issues + else {} + ) + for parent in parents: + profile = parent_profiles[parent_keys[parent.id]] + snapshot = assemble_metadata( + MetadataEntityKind.SERIES, + [ + ExternalIdentityRef( + profile.identity_namespace, MetadataEntityKind.SERIES, profile.external_id + ) + ], + [profile], + policies, + now=now, + ) + parent.cover_url = snapshot.values.image_url + await persist_adoption_series_baseline(session, parent, snapshot) + writes = [] + for issue in issues: + issue_profile = issue_profiles[issue_keys[issue.id]] + snapshot = assemble_metadata( + MetadataEntityKind.ISSUE, + [ + ExternalIdentityRef( + issue_profile.identity_namespace, + MetadataEntityKind.ISSUE, + issue_profile.external_id, + ) + ], + [ + issue_profile.model_copy( + update={"issue_number_text": issue.effective_issue_number_text} + ) + ], + policies, + now=now, + parent_identities=[ + ExternalIdentityRef( + issue_profile.identity_namespace, + MetadataEntityKind.SERIES, + issue_profile.series_external_id, + ) + ], + ) + issue.cover_url = snapshot.values.image_url + writes.append(MetadataBaselineWrite(issue.id, snapshot)) + for offset in range(0, len(writes), 200): + await save_metadata_baselines(session, writes[offset : offset + 200]) diff --git a/src/pullbox/services/story_arc_catalog.py b/src/pullbox/services/story_arc_catalog.py index 12576202..a389ee22 100644 --- a/src/pullbox/services/story_arc_catalog.py +++ b/src/pullbox/services/story_arc_catalog.py @@ -20,11 +20,14 @@ StoryArcResolutionState, StoryArcSourceKind, ) +from pullbox.services.metadata_arc_baselines import persist_arc_metadata from pullbox.services.metadata_arc_catalog import ( MAX_CATALOG_MEMBERS, MAX_CATALOG_PARENTS, project_source_arc_catalog, ) +from pullbox.services.metadata_assembly import MetadataAssemblyError +from pullbox.services.metadata_baselines import MetadataBaselineConflictError from pullbox.services.story_arc_catalog_evidence import ( record_catalog_crosswalks, require_crosswalk_ownership, @@ -242,6 +245,7 @@ async def add( arc.policy_schema_version = STORY_ARC_PLACEMENT_POLICY_SCHEMA_VERSION arc.policy_snapshot = policy.snapshot await attach_arc_identity(session, arc, preview) + await persist_arc_metadata(session, arc, preview, created=True) issues = await seed_members(session, preview, root, order) await record_catalog_crosswalks(session, preview) for position, provider_id in enumerate(order, start=1): @@ -263,6 +267,11 @@ async def add( await enqueue_story_arc_sync_work(session, library_file) await initialize_catalog_placements(session, arc) return arc + except (MetadataBaselineConflictError, MetadataAssemblyError) as exc: + raise StoryArcCatalogError( + "metadata_baseline_conflict", + "Metadata baseline needs review; preview the arc again", + ) from exc except IntegrityError as exc: raise StoryArcCatalogError( "identity_conflict", "Catalog identities changed; refresh the preview" @@ -330,6 +339,7 @@ async def refresh( *, expected_revision: int, library_root_id: int | None = None, + replace_metadata: bool = True, ) -> StoryArcCatalogRefreshResult: delta = await self.preview_refresh(session, story_arc_id, preview) if isinstance(expected_revision, bool) or delta.revision != expected_revision: @@ -355,6 +365,9 @@ async def refresh( "revision_conflict", "Story arc changed; refresh the review" ) await attach_arc_identity(session, arc, preview) + await persist_arc_metadata( + session, arc, preview, created=False, replace_managed=replace_metadata + ) issues = await seed_members( session, preview, root, preview.metadata.issue_provider_ids ) @@ -395,12 +408,18 @@ async def refresh( pending = tuple( row.id for row in rows if row.evidence.get("catalog_review_required") is True ) + tuple(created) - arc.cover_url = preview.metadata.cover_url + if preview.source_evidence is None: + arc.cover_url = preview.metadata.cover_url self._diagnostics(arc, preview, root.id, delta.removed_issue_provider_ids, pending) await session.flush() return StoryArcCatalogRefreshResult( arc, tuple(created), delta.removed_issue_provider_ids ) + except (MetadataBaselineConflictError, MetadataAssemblyError) as exc: + raise StoryArcCatalogError( + "metadata_baseline_conflict", + "Metadata baseline needs review; preview the arc again", + ) from exc except IntegrityError as exc: raise StoryArcCatalogError( "identity_conflict", "Catalog identities changed; refresh the preview" diff --git a/src/pullbox/services/story_arc_catalog_persistence.py b/src/pullbox/services/story_arc_catalog_persistence.py index 2a2b9ad9..31e4cdd8 100644 --- a/src/pullbox/services/story_arc_catalog_persistence.py +++ b/src/pullbox/services/story_arc_catalog_persistence.py @@ -94,6 +94,8 @@ async def seed_members( series_metadata = {series.provider_id: series for series in preview.series} result: dict[str, Issue] = {} parents: dict[str, Series] = {} + new_parents: list[Series] = [] + new_issues: list[Issue] = [] parent_owners = await catalog_owners( session, MetadataEntityKind.SERIES, @@ -131,6 +133,7 @@ async def seed_members( parent = await _new_series( session, parent_metadata, root, preview.source, profile=profile ) + new_parents.append(parent) parents[parent_key] = parent number, exact_number = parse_issue_number_text( metadata.issue_number_text or metadata.issue_number @@ -182,8 +185,12 @@ async def seed_members( ) session.add(issue) await session.flush() + new_issues.append(issue) result[provider_id] = issue await _attach_member_identities(session, preview, parents, result) + from pullbox.services.metadata_arc_baselines import persist_seeded_metadata + + await persist_seeded_metadata(session, preview, new_parents, new_issues) return result diff --git a/src/pullbox/tasks/story_arc_metadata_task.py b/src/pullbox/tasks/story_arc_metadata_task.py index 7a6c337e..5dfba158 100644 --- a/src/pullbox/tasks/story_arc_metadata_task.py +++ b/src/pullbox/tasks/story_arc_metadata_task.py @@ -201,6 +201,7 @@ async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) - preview, expected_revision=revision, library_root_id=default_roots[0] if len(default_roots) == 1 else None, + replace_metadata=False, ) await session.commit() # The wanted sweep rechecks monitoring, skips, dates and duplicates. diff --git a/tests/integration/metadata_identity/test_arc_metadata_baselines.py b/tests/integration/metadata_identity/test_arc_metadata_baselines.py new file mode 100644 index 00000000..9fb3001e --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_metadata_baselines.py @@ -0,0 +1,308 @@ +"""Canonical history follows real arc commands without taking ownership of local edits.""" + +from dataclasses import replace + +import pytest +from sqlalchemy import func, select, update + +from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource +from pullbox.models import Issue, Series, StoryArc +from pullbox.models.metadata_baseline import ( + IssueMetadataBaseline, + SeriesMetadataBaseline, + StoryArcMetadataBaseline, +) +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.story_arc import ( + IssueStoryArc, + StoryArcPlacement, + StoryArcPlacementMode, + StoryArcPlacementOwnership, + StoryArcResolutionState, +) +from pullbox.services.metadata_arc_catalog import project_source_arc_catalog +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.story_arc_catalog import StoryArcCatalogError, StoryArcCatalogService +from tests.integration.metadata_identity.test_source_arc_catalog import _add, _service, _setup +from tests.unit.test_source_arc_catalog_fetch import CatalogAdapter, fetch + + +async def _preview(count=2, **changes): + adapter = CatalogAdapter(count) + adapter.arc = adapter.arc.model_copy( + update={ + "title": "Original arc", + "description": "Provider description", + "publisher": "Original publisher", + "image_url": "https://static.metron.cloud/media/arc.jpg", + **changes, + } + ) + return await fetch(adapter) + + +@pytest.mark.parametrize("source", [MetadataSource.METRON_API, MetadataSource.COMICVINE_API]) +async def test_arc_add_persists_graph_baselines_across_reconnect( + identity_probe_db, tmp_path, source +): + engine, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview() + if source is MetadataSource.COMICVINE_API: + async with factory.begin() as session: + session.add( + MetadataSourceConfig(source=source.value, enabled=True, priority=2, revision=1) + ) + evidence = preview.source_evidence + changes = {"source": source, "identity_namespace": source.identity_namespace} + preview = project_source_arc_catalog( + replace( + evidence, + arc=evidence.arc.model_copy(update=changes), + series=tuple(row.model_copy(update=changes) for row in evidence.series), + issues=tuple(row.model_copy(update=changes) for row in evidence.issues), + ), + 1, + ) + async with factory.begin() as session: + arc = await _add( + StoryArcCatalogService(source=source, source_revision=1), session, preview, root + ) + arc_id = arc.id + await engine.dispose() + async with factory() as session: + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert saved is not None, "Add must retain canonical arc provenance" + assert saved.revision == 1 + assert saved.snapshot.values.title == "Original arc" + assert saved.snapshot.values.publisher == "Original publisher" + assert all(origin.source == preview.source for origin in saved.snapshot.origins) + for parent in await session.scalars(select(Series)): + baseline = await load_metadata_baseline(session, MetadataEntityKind.SERIES, parent.id) + assert baseline is not None, "Targeted parent seeding needs provenance too" + assert baseline.snapshot.values.language == "en" + assert baseline.snapshot.values.series_type == parent.series_type.value + assert baseline.snapshot.values.sort_title == parent.sort_title + assert parent.issue_catalog_state.value == "partial" and not parent.monitored + for issue in await session.scalars(select(Issue)): + baseline = await load_metadata_baseline(session, MetadataEntityKind.ISSUE, issue.id) + assert baseline is not None + assert baseline.snapshot.values.issue_number_text == issue.effective_issue_number_text + assert baseline.snapshot.values.image_url == issue.cover_url + assert not list((tmp_path / "comics").iterdir()) + + +async def test_arc_refresh_updates_managed_fields_preserves_clears_and_member_order( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + original = await _preview() + async with factory.begin() as session: + arc = await _service().add( + session, + original, + ordered_issue_provider_ids=["1001", "1000"], + skipped_issue_provider_ids=["1000"], + library_root_id=root, + ) + arc.description = None + arc.cover_url = None + arc_id, revision = arc.id, arc.revision + changed = await _preview(3, title="Updated provider arc", publisher="Updated publisher") + async with factory.begin() as session: + await _service().refresh(session, arc_id, changed, expected_revision=revision) + await engine.dispose() + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.name == "Updated provider arc", "Refresh must update provider-managed names" + assert arc.normalized_name == "updated provider arc" + assert arc.description is None and arc.cover_url is None + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert saved is not None and saved.revision == 2 + assert saved.snapshot.values.publisher == "Updated publisher" + origins = {origin.field: origin for origin in saved.snapshot.origins} + assert origins["description"].user_override and origins["image_url"].user_override + members = list( + await session.scalars( + select(IssueStoryArc) + .where(IssueStoryArc.story_arc_id == arc_id) + .order_by(IssueStoryArc.sequence_number) + ) + ) + assert [member.source_issue_id for member in members] == ["1001", "1000", "1002"] + assert members[1].resolution_state == StoryArcResolutionState.SKIPPED + assert members[2].evidence["catalog_review_required"] is True + assert not members[2].sync_eligible + assert not list((tmp_path / "comics").iterdir()) + + +async def test_arc_without_baseline_keeps_unknown_local_values(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview(1) + async with factory.begin() as session: + arc = await _add(_service(), session, preview, root) + arc_id, revision = arc.id, arc.revision + baseline = await session.scalar(select(StoryArcMetadataBaseline)) + if baseline is not None: + await session.delete(baseline) + arc.name = "My established title" + arc.description = None + changed = await _preview(1, title="Different upstream title") + async with factory.begin() as session: + await _service().refresh(session, arc_id, changed, expected_revision=revision) + arc = await session.get(StoryArc, arc_id) + assert arc.name == "My established title" + assert arc.description == "Provider description", "Unknown history may fill empty fields" + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert saved is not None + title_origin = next(item for item in saved.snapshot.origins if item.field == "title") + assert title_origin.source is None + + +async def test_reused_members_keep_their_values_and_baseline_history(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + original = await _preview(1) + async with factory.begin() as session: + await _add(_service(), session, original, root) + parent = await session.scalar(select(Series)) + issue = await session.scalar(select(Issue)) + parent.title, parent.path, parent.monitored = "My parent", "/unchanged", True + issue.title, issue.status, issue.manual_skip = "My issue", "owned", True + parent_id, issue_id = parent.id, issue.id + before = await load_metadata_baseline(session, MetadataEntityKind.ISSUE, issue_id) + fresh = await _preview(2) + evidence = replace( + fresh.source_evidence, + arc=fresh.source_evidence.arc.model_copy(update={"external_id": "99"}), + ) + fresh = project_source_arc_catalog(evidence, fresh.source_revision) + async with factory.begin() as session: + await _add(_service(), session, fresh, root) + parent, issue = await session.get(Series, parent_id), await session.get(Issue, issue_id) + assert (parent.title, parent.path, parent.monitored) == ("My parent", "/unchanged", True) + assert (issue.title, issue.status, issue.manual_skip) == ("My issue", "owned", True) + assert before is not None, "New members need baselines before another arc reuses them" + assert await load_metadata_baseline(session, MetadataEntityKind.ISSUE, issue_id) == before + assert await session.scalar(select(func.count()).select_from(IssueMetadataBaseline)) == 2 + + +async def test_corrupt_arc_baseline_rejects_refresh_atomically(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + original = await _preview(1) + async with factory.begin() as session: + arc = await _add(_service(), session, original, root) + arc_id, revision = arc.id, arc.revision + await session.execute(update(StoryArcMetadataBaseline).values(snapshot_json="{}")) + changed = await _preview(2) + async with factory.begin() as session: + with pytest.raises(StoryArcCatalogError, match="baseline"): + await _service().refresh(session, arc_id, changed, expected_revision=revision) + assert (await session.get(StoryArc, arc_id)).revision == revision + assert await session.scalar(select(func.count()).select_from(Issue)) == 1 + + +async def test_arc_caller_rollback_discards_all_baselines(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview() + async with factory() as session: + await _add(_service(), session, preview, root) + assert await session.scalar(select(func.count()).select_from(StoryArcMetadataBaseline)) == 1 + await session.rollback() + async with factory() as session: + for model in (StoryArcMetadataBaseline, SeriesMetadataBaseline, IssueMetadataBaseline): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + + +async def test_refresh_retains_placed_arc_name_without_blocking_safe_updates( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + original = await _preview(1) + async with factory.begin() as session: + arc = await _add(_service(), session, original, root) + member = await session.scalar(select(IssueStoryArc)) + session.add( + StoryArcPlacement( + issue_story_arc_id=member.id, + placement_path="/arcs/Original arc/001.cbz", + mode=StoryArcPlacementMode.COPY, + ownership=StoryArcPlacementOwnership.MANAGED, + ) + ) + arc_id, revision = arc.id, arc.revision + changed = await _preview(2, title="Changed upstream name", description="Updated description") + async with factory.begin() as session: + await _service().refresh(session, arc_id, changed, expected_revision=revision) + arc = await session.get(StoryArc, arc_id) + assert arc.name == "Original arc", "Refresh cannot bypass the managed-placement name guard" + assert arc.description == "Updated description" + assert await session.scalar(select(func.count()).select_from(IssueStoryArc)) == 2 + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert "title_retained_for_managed_placements" in saved.snapshot.diagnostics + origin = next(item for item in saved.snapshot.origins if item.field == "title") + assert not origin.user_override, "A placement constraint is not a user edit" + placement = await session.scalar(select(StoryArcPlacement)) + assert placement.placement_path == "/arcs/Original arc/001.cbz" + + +async def test_large_arc_seeding_uses_bounded_baseline_batches( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import metadata_arc_baselines + + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview(205) + actual = metadata_arc_baselines.save_metadata_baselines + batches = [] + + async def record_batch(session, writes): + batches.append(len(writes)) + return await actual(session, writes) + + monkeypatch.setattr(metadata_arc_baselines, "save_metadata_baselines", record_batch) + async with factory.begin() as session: + await _add(_service(), session, preview, root) + assert await session.scalar(select(func.count()).select_from(IssueMetadataBaseline)) == 205 + assert await session.scalar(select(func.count()).select_from(SeriesMetadataBaseline)) == 2 + assert batches == [1, 200, 5] + + +async def test_late_baseline_failure_rolls_back_graph_without_losing_callers_work( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import metadata_arc_baselines + from pullbox.services.metadata_baselines import MetadataBaselineConflictError + + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview(2) + actual = metadata_arc_baselines.save_metadata_baselines + + async def reject_issue_batch(session, writes): + if any(item.snapshot.entity_kind is MetadataEntityKind.ISSUE for item in writes): + raise MetadataBaselineConflictError("Synthetic concurrent baseline change") + return await actual(session, writes) + + monkeypatch.setattr(metadata_arc_baselines, "save_metadata_baselines", reject_issue_batch) + async with factory.begin() as session: + unrelated = Series(title="Unrelated work", sort_title="Unrelated work") + session.add(unrelated) + await session.flush() + with pytest.raises(StoryArcCatalogError, match="baseline"): + await _add(_service(), session, preview, root) + assert await session.scalar(select(Series.title)) == "Unrelated work" + for model in ( + StoryArc, + Issue, + StoryArcMetadataBaseline, + SeriesMetadataBaseline, + IssueMetadataBaseline, + ): + assert await session.scalar(select(func.count()).select_from(model)) == 0 diff --git a/tests/integration/metadata_identity/test_source_arc_commands.py b/tests/integration/metadata_identity/test_source_arc_commands.py index 6d2018cc..ac7d3986 100644 --- a/tests/integration/metadata_identity/test_source_arc_commands.py +++ b/tests/integration/metadata_identity/test_source_arc_commands.py @@ -6,6 +6,11 @@ from sqlalchemy import event, func, select from pullbox.models import Issue, Series, StoryArc +from pullbox.models.metadata_baseline import ( + IssueMetadataBaseline, + SeriesMetadataBaseline, + StoryArcMetadataBaseline, +) from pullbox.models.metadata_identity import ( IssueIdentityEvent, SeriesIdentityEvent, @@ -72,6 +77,9 @@ def reject_commit(_session): StoryArcIdentityEvent, SeriesIdentityEvent, IssueIdentityEvent, + StoryArcMetadataBaseline, + SeriesMetadataBaseline, + IssueMetadataBaseline, ): count = await session.scalar(select(func.count()).select_from(model)) assert bool(count) is (failure == "none") diff --git a/tests/integration/metadata_identity/test_source_arc_monitoring.py b/tests/integration/metadata_identity/test_source_arc_monitoring.py index cc6992cc..cc177ec0 100644 --- a/tests/integration/metadata_identity/test_source_arc_monitoring.py +++ b/tests/integration/metadata_identity/test_source_arc_monitoring.py @@ -173,3 +173,33 @@ async def test_provider_failure_is_recorded_without_mutation( arc = await session.get(StoryArc, arc_id) assert arc.diagnostics["provider_refresh_error"]["code"] == "source_unavailable" assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +async def test_scheduled_arc_enrichment_fills_gaps_without_replacing_managed_values( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.core.metadata_identity import MetadataEntityKind + from pullbox.services.metadata_baselines import load_metadata_baseline + + _, factory, _ = identity_probe_db + arc_id, adapter = await setup(factory, tmp_path, monkeypatch) + async with factory() as session: + before = await session.get(StoryArc, arc_id) + title = before.name + assert before.description is None + adapter.arc = adapter.arc.model_copy( + update={ + "title": "Different upstream title", + "description": "A newly supplied description", + } + ) + await task.sync_story_arc_metadata() + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.name == title, "Background enrichment must not act like explicit refresh" + assert arc.description == "A newly supplied description" + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert saved is not None and saved.snapshot.values.title == title + assert saved.snapshot.values.description == arc.description + assert not any(origin.user_override for origin in saved.snapshot.origins) + assert len(list(await session.scalars(select(IssueStoryArc)))) == 2 From 34a83cb4ef74c622e7765838c9d74c3ddeab3a3d Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 18:22:41 -0700 Subject: [PATCH 55/97] feat(metadata): cascade story arc descriptive refresh --- docs/development/ARCHITECTURE_OVERVIEW.md | 14 +- docs/development/DATABASE_STANDARDS.md | 7 + .../services/metadata_arc_baselines.py | 5 +- src/pullbox/services/metadata_arc_commands.py | 3 + src/pullbox/services/metadata_arc_refresh.py | 211 +++++++++++++ .../services/metadata_refresh_snapshot.py | 9 + .../services/metadata_series_refresh_state.py | 2 +- src/pullbox/services/story_arc_catalog.py | 27 +- src/pullbox/tasks/story_arc_metadata_task.py | 8 + tests/e2e/test_story_arc_catalog_page.py | 71 ++++- .../test_arc_metadata_cascade.py | 298 ++++++++++++++++++ tests/unit/test_metadata_refresh_snapshot.py | 24 ++ 12 files changed, 665 insertions(+), 14 deletions(-) create mode 100644 src/pullbox/services/metadata_arc_refresh.py create mode 100644 tests/integration/metadata_identity/test_arc_metadata_cascade.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 1d3f4f3a..777e5f68 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -423,9 +423,17 @@ blocking other safe changes; the baseline records that restriction separately from user overrides. Existing order, skipped and removed members, partial parent catalogs and pending-placement review remain unchanged. Legacy snapshots without normalized source evidence are not silently assigned canonical provenance. -Multi-source Arc descriptive-read integration, remaining credits/membership fields -and coordinated XML/sidecar writers are still pending; baselines alone do not -complete those workflows. +Source-bound manual and scheduled Arc refresh use the shared descriptive cascade +over verified attached identities. Fresh catalog evidence is reused without a +second read of that transport; higher-priority sources can supply descriptive +fields without taking over membership. The read set captures values, baseline, +identity revisions/history, arc state, placement restrictions and source policies. +Provider I/O runs after releasing the reader. Policy-first/arc locks and read-set +revalidation precede the atomic catalog write; concurrent changes reject the whole +refresh. Partial source outcomes are retained as bounded diagnostics. New Add and +legacy preview compatibility still use their selected source; remaining legacy +consumer migration, complete credits/membership fields and coordinated XML/sidecar +writers are pending. This does not complete the full metadata feature. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index f34e6ac1..05afa164 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -456,6 +456,13 @@ share that transaction; pre-existing rows are not retrospectively attributed to the incoming source. Issue baseline writes are batched at 200. Refresh reloads current mapped fields after claiming the arc, retaining user changes and clears. Corrupt provenance aborts the entire refresh, including membership additions. +Source-bound multi-source refresh captures an immutable read set before provider +I/O and releases its read transaction. It locks source policies before the arc, +then compares mapped fields, baseline revision/payload, identity claims/history, +arc revision/lifecycle/monitoring and managed-placement presence before saving. +A stale read aborts the complete membership and metadata write, including when a +caller catches the domain error. Secondary sources never acquire membership or +identity ownership through descriptive enrichment. ### Source Catalog Checkpoints diff --git a/src/pullbox/services/metadata_arc_baselines.py b/src/pullbox/services/metadata_arc_baselines.py index 7bb67d55..4472d6ec 100644 --- a/src/pullbox/services/metadata_arc_baselines.py +++ b/src/pullbox/services/metadata_arc_baselines.py @@ -20,7 +20,7 @@ MetadataValues, field_domain, ) -from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_assembly import MetadataCandidate, assemble_metadata from pullbox.services.metadata_baselines import ( MetadataBaselineWrite, load_metadata_baseline, @@ -39,6 +39,7 @@ async def persist_arc_metadata( *, created: bool, replace_managed: bool = False, + candidates: Sequence[MetadataCandidate] | None = None, ) -> None: """Apply descriptive fields only; membership and placements retain their own rules.""" evidence = preview.source_evidence @@ -79,7 +80,7 @@ async def persist_arc_metadata( snapshot = assemble_metadata( kind, identities, - [evidence.arc], + candidates if candidates is not None else [evidence.arc], await read_source_policies(session), now=now, current=current, diff --git a/src/pullbox/services/metadata_arc_commands.py b/src/pullbox/services/metadata_arc_commands.py index 0231fcbe..6cf0508f 100644 --- a/src/pullbox/services/metadata_arc_commands.py +++ b/src/pullbox/services/metadata_arc_commands.py @@ -17,6 +17,7 @@ ) from pullbox.schemas.metadata_sources import StoryArcPreviewQuery from pullbox.services.metadata_arc_catalog import fetch_source_arc_catalog +from pullbox.services.metadata_arc_refresh import prepare_arc_metadata from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_sources import load_source_runtime @@ -161,6 +162,7 @@ async def source_arc_refresh_transaction( decision: ArcCatalogRefresh, ) -> AsyncIterator[ArcCommandResult]: _require_decision(session, preview, decision) + metadata_refresh = await prepare_arc_metadata(session, story_arc_id, preview) async with session.begin(): result = await catalog_writer(preview).refresh( session, @@ -168,6 +170,7 @@ async def source_arc_refresh_transaction( preview, expected_revision=decision.expected_revision, library_root_id=decision.library_root_id, + metadata_refresh=metadata_refresh, ) yield ArcCommandResult( result.story_arc, diff --git a/src/pullbox/services/metadata_arc_refresh.py b/src/pullbox/services/metadata_arc_refresh.py new file mode 100644 index 00000000..31c1fd4c --- /dev/null +++ b/src/pullbox/services/metadata_arc_refresh.py @@ -0,0 +1,211 @@ +"""Exact-identity descriptive reads separated from Story Arc catalog writes.""" + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.config import get_settings +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_identity import StoryArcIdentityEvent +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.publisher import Publisher +from pullbox.schemas.metadata_snapshot import MetadataValues +from pullbox.schemas.metadata_sources import SourcePolicyRead +from pullbox.services.metadata_assembly import MetadataAssemblyError +from pullbox.services.metadata_baselines import ( + MetadataBaselineConflictError, + load_metadata_baseline, +) +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_read_cache import source_read_cache +from pullbox.services.metadata_refresh_snapshot import ( + MetadataRefreshSnapshot, + fetch_metadata_snapshot, +) +from pullbox.services.metadata_series_refresh_state import RefreshEntityState +from pullbox.services.metadata_sources import load_source_runtime, read_source_policies +from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, StoryArcCatalogPreview +from pullbox.services.story_arc_service import StoryArcService + +ARC_FIELDS = frozenset({"title", "description", "publisher", "image_url"}) + + +@dataclass(frozen=True) +class ArcRefreshState: + entity: RefreshEntityState + revision: int + lifecycle: str + monitored: bool + placed: bool + policies: tuple[SourcePolicyRead, ...] + + +@dataclass(frozen=True) +class ArcMetadataRefresh: + before: ArcRefreshState + fetched: MetadataRefreshSnapshot + + +async def read_arc_refresh_state(session: AsyncSession, arc_id: int) -> ArcRefreshState: + arc = await session.scalar( + select(StoryArc).where(StoryArc.id == arc_id).execution_options(populate_existing=True) + ) + if arc is None: + raise StoryArcCatalogError("arc_unavailable", "Story arc is unavailable") + try: + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + except MetadataBaselineConflictError as exc: + raise StoryArcCatalogError( + "metadata_baseline_conflict", "Metadata baseline needs review; preview the arc again" + ) from exc + values = saved.snapshot.values.model_dump() if saved else {} + values.update( + title=arc.name, + description=arc.description, + image_url=arc.cover_url, + publisher=await session.scalar( + select(Publisher.name).where(Publisher.id == arc.publisher_id) + ), + ) + claims = tuple( + ( + ExternalIdentityRef( + IdentityNamespace(row.source), MetadataEntityKind.STORY_ARC, row.external_id + ), + row.verification_state, + row.revision, + ) + for row in await session.scalars( + select(StoryArcExternalIdentity) + .where( + StoryArcExternalIdentity.story_arc_id == arc_id, + StoryArcExternalIdentity.namespace == "story_arc", + StoryArcExternalIdentity.source.in_([item.value for item in IdentityNamespace]), + ) + .order_by(StoryArcExternalIdentity.source) + .execution_options(populate_existing=True) + ) + ) + if arc.comicvine_id and not any( + ref.namespace is IdentityNamespace.COMICVINE for ref, _, _ in claims + ): + claims += ( + ( + ExternalIdentityRef( + IdentityNamespace.COMICVINE, MetadataEntityKind.STORY_ARC, str(arc.comicvine_id) + ), + IdentityVerificationState.VERIFIED, + 0, + ), + ) + event_id = await session.scalar( + select(func.max(StoryArcIdentityEvent.id)).where( + StoryArcIdentityEvent.story_arc_id == arc_id + ) + ) + policies = tuple( + item.model_copy( + update={ + "last_tested_at": None, + "last_success_at": None, + "last_status": None, + } + ) + for item in await read_source_policies(session) + ) + return ArcRefreshState( + RefreshEntityState( + arc_id, + MetadataValues.model_validate(values), + saved.snapshot if saved else None, + saved.revision if saved else 0, + claims, + event_id or 0, + arc.comicvine_id, + ), + arc.revision, + arc.lifecycle.value, + arc.monitored, + await StoryArcService._has_managed_placements(session, story_arc_id=arc_id), + policies, + ) + + +async def fetch_arc_metadata( + registry: MetadataSourceRegistry, + before: ArcRefreshState, + preview: StoryArcCatalogPreview, + *, + replace_managed: bool, +) -> ArcMetadataRefresh: + evidence = preview.source_evidence + if evidence is None: + raise StoryArcCatalogError("snapshot_changed", "Preview current source metadata again") + try: + fetched = await fetch_metadata_snapshot( + registry, + MetadataEntityKind.STORY_ARC, + before.entity.identities, + now=datetime.now(UTC), + requested_fields=ARC_FIELDS - {"title"} if before.placed else ARC_FIELDS, + current=before.entity.values, + previous=before.entity.baseline, + replace_managed=replace_managed, + initial_candidates=[evidence.arc], + ) + except MetadataAssemblyError as exc: + raise StoryArcCatalogError( + "metadata_conflict", "Source metadata needs review before refresh" + ) from exc + return ArcMetadataRefresh(before, fetched) + + +async def prepare_arc_metadata( + session: AsyncSession, + arc_id: int, + preview: StoryArcCatalogPreview, + *, + replace_managed: bool = True, +) -> ArcMetadataRefresh: + if session.new or session.dirty or session.deleted: + raise ValueError("Finish pending edits before refreshing Story Arc metadata") + try: + before = await read_arc_refresh_state(session, arc_id) + enabled = get_settings().metadata_gcd_api_v2_enabled + runtime = await load_source_runtime(session, gcd_api_enabled=enabled) + registry = MetadataSourceRegistry( + runtime, + gcd_api_enabled=enabled, + read_cache=source_read_cache(session), + revalidate_reads=True, + total_timeout=60, + ) + finally: + await session.rollback() + return await fetch_arc_metadata(registry, before, preview, replace_managed=replace_managed) + + +async def require_arc_refresh_state(session: AsyncSession, refresh: ArcMetadataRefresh) -> None: + # Match the shared policy-first, then parent-first writer lock order. + await session.execute( + select(MetadataSourceConfig) + .order_by(MetadataSourceConfig.source) + .with_for_update(read=True) + ) + await session.execute( + select(StoryArc.id).where(StoryArc.id == refresh.before.entity.local_id).with_for_update() + ) + current = await read_arc_refresh_state(session, refresh.before.entity.local_id) + if current != refresh.before: + raise StoryArcCatalogError( + "metadata_changed", + "Story arc metadata, identities or source settings changed; refresh the review", + ) diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py index 90977ec3..f32434ef 100644 --- a/src/pullbox/services/metadata_refresh_snapshot.py +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -44,6 +44,7 @@ async def fetch_metadata_snapshot( overrides: frozenset[str] = frozenset(), replace_managed: bool = False, parent_identities: Sequence[ExternalIdentityRef] = (), + initial_candidates: Sequence[MetadataCandidate] = (), ) -> MetadataRefreshSnapshot: """Fetch only attached identities and stop each domain at its first usable value. @@ -122,6 +123,14 @@ def assemble(*, local: bool) -> MetadataSnapshot: raise MetadataAssemblyError("Issue refresh requires verified parent identities.") remaining.add(source) + # A freshly fetched catalog may already include this exact descriptive row. + # Validate it through the same assembler, but still consult higher priorities. + candidates.extend(initial_candidates) + snapshot = assemble(local=True) + for candidate in initial_candidates: + remaining.discard(candidate.source) + outcomes[candidate.source] = SourceOutcome(source=candidate.source, status=SourceStatus.OK) + def rank(source: MetadataSource, field: str) -> tuple[int, str]: policy = reader.runtime[source].policy domain = field_domain(kind, field) diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index e3221eae..84a052ea 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -62,7 +62,7 @@ class RefreshEntityState: values: MetadataValues baseline: MetadataSnapshot | None baseline_revision: int - claims: tuple[tuple[ExternalIdentityRef, IdentityVerificationState, int], ...] + claims: tuple[tuple[ExternalIdentityRef, IdentityVerificationState | None, int], ...] event_id: int comicvine_id: int | None overrides: frozenset[str] = frozenset() diff --git a/src/pullbox/services/story_arc_catalog.py b/src/pullbox/services/story_arc_catalog.py index a389ee22..1436db3a 100644 --- a/src/pullbox/services/story_arc_catalog.py +++ b/src/pullbox/services/story_arc_catalog.py @@ -26,6 +26,7 @@ MAX_CATALOG_PARENTS, project_source_arc_catalog, ) +from pullbox.services.metadata_arc_refresh import ArcMetadataRefresh, require_arc_refresh_state from pullbox.services.metadata_assembly import MetadataAssemblyError from pullbox.services.metadata_baselines import MetadataBaselineConflictError from pullbox.services.story_arc_catalog_evidence import ( @@ -340,7 +341,10 @@ async def refresh( expected_revision: int, library_root_id: int | None = None, replace_metadata: bool = True, + metadata_refresh: ArcMetadataRefresh | None = None, ) -> StoryArcCatalogRefreshResult: + if metadata_refresh is not None and metadata_refresh.before.entity.local_id != story_arc_id: + raise StoryArcCatalogError("metadata_changed", "Story arc changed; refresh the review") delta = await self.preview_refresh(session, story_arc_id, preview) if isinstance(expected_revision, bool) or delta.revision != expected_revision: raise StoryArcCatalogError("revision_conflict", "Story arc changed; refresh the review") @@ -353,6 +357,8 @@ async def refresh( await self._enclose_savepoint(session) try: async with session.begin_nested(): + if metadata_refresh is not None: + await require_arc_refresh_state(session, metadata_refresh) await require_catalog_source_revision(session, preview) await require_crosswalk_ownership(session, preview) claimed = await session.execute( @@ -366,7 +372,12 @@ async def refresh( ) await attach_arc_identity(session, arc, preview) await persist_arc_metadata( - session, arc, preview, created=False, replace_managed=replace_metadata + session, + arc, + preview, + created=False, + replace_managed=replace_metadata, + candidates=metadata_refresh.fetched.candidates if metadata_refresh else None, ) issues = await seed_members( session, preview, root, preview.metadata.issue_provider_ids @@ -411,6 +422,20 @@ async def refresh( if preview.source_evidence is None: arc.cover_url = preview.metadata.cover_url self._diagnostics(arc, preview, root.id, delta.removed_issue_provider_ids, pending) + if metadata_refresh is not None: + arc.diagnostics = { + **arc.diagnostics, + "metadata_refresh": { + "checked_at": datetime.now(UTC).isoformat(), + "outcomes": [ + item.model_dump( + mode="json", + include={"source", "status", "retry_after_seconds"}, + ) + for item in metadata_refresh.fetched.outcomes + ], + }, + } await session.flush() return StoryArcCatalogRefreshResult( arc, tuple(created), delta.removed_issue_provider_ids diff --git a/src/pullbox/tasks/story_arc_metadata_task.py b/src/pullbox/tasks/story_arc_metadata_task.py index 5dfba158..8f8e7919 100644 --- a/src/pullbox/tasks/story_arc_metadata_task.py +++ b/src/pullbox/tasks/story_arc_metadata_task.py @@ -22,6 +22,7 @@ from pullbox.services.import_activity import has_active_import_scheduler_protection from pullbox.services.metadata_arc_catalog import StoryArcSourceError, fetch_source_arc_catalog from pullbox.services.metadata_arc_commands import catalog_writer +from pullbox.services.metadata_arc_refresh import fetch_arc_metadata, read_arc_refresh_state from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_sources import load_source_runtime @@ -150,6 +151,7 @@ async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) - revision = arc.revision registry = await _registry(session) targets = await _targets(session, arc, registry) + before = await read_arc_refresh_state(session, arc_id) if not targets: return None try: @@ -176,6 +178,11 @@ async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) - if preview is None: assert failure is not None raise failure + if await has_active_import_scheduler_protection(factory): + return None + metadata_refresh = await fetch_arc_metadata( + registry, before, preview, replace_managed=False + ) if await has_active_import_scheduler_protection(factory): return None async with factory() as session: @@ -202,6 +209,7 @@ async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) - expected_revision=revision, library_root_id=default_roots[0] if len(default_roots) == 1 else None, replace_metadata=False, + metadata_refresh=metadata_refresh, ) await session.commit() # The wanted sweep rechecks monitoring, skips, dates and duplicates. diff --git a/tests/e2e/test_story_arc_catalog_page.py b/tests/e2e/test_story_arc_catalog_page.py index 04cd2e09..6b282ade 100644 --- a/tests/e2e/test_story_arc_catalog_page.py +++ b/tests/e2e/test_story_arc_catalog_page.py @@ -4,41 +4,84 @@ import re from dataclasses import replace +from datetime import UTC, datetime, timedelta from pathlib import Path -from unittest.mock import AsyncMock +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, Mock from urllib.parse import parse_qs, urlparse +from uuid import uuid4 import pytest from playwright.sync_api import Page, Route, expect +from sqlalchemy import delete, select +from pullbox.database import get_session_factory +from pullbox.models import StoryArc from tests.e2e.accessibility import assert_no_axe_violations -from tests.e2e.conftest import _TEST_COVER_PNG +from tests.e2e.conftest import _TEST_COVER_PNG, _run_async_blocking from tests.e2e.story_arc_file_helpers import configure_arc_file_defaults from tests.story_arc_catalog_fixtures import CatalogProvider +if TYPE_CHECKING: + from collections.abc import Callable, Generator + pytestmark = pytest.mark.e2e +async def _arc_ids() -> tuple[int, ...]: + async with get_session_factory()() as session: + return tuple(await session.scalars(select(StoryArc.id))) + + +async def _remove_created_arcs(existing: tuple[int, ...]) -> None: + async with get_session_factory().begin() as session: + await session.execute(delete(StoryArc).where(StoryArc.id.not_in(existing))) + + @pytest.fixture -def catalog_provider(monkeypatch: pytest.MonkeyPatch, page: Page) -> CatalogProvider: +def catalog_provider( + monkeypatch: pytest.MonkeyPatch, page: Page, seeded_server: str +) -> Generator[CatalogProvider, None, None]: + # Each browser creates the same fixture identities in the session-scoped DB. + # Remove only arcs created by this test, never the original library seed. + existing = _run_async_blocking(_arc_ids()) page.route( "https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg", lambda route: route.fulfill(status=200, content_type="image/png", body=_TEST_COVER_PNG), ) provider = CatalogProvider() + credential = f"arc-browser-{uuid4().hex}" monkeypatch.setattr( - "pullbox.core.comicvine_key.get_comicvine_api_key", AsyncMock(return_value="test") + "pullbox.core.comicvine_key.get_comicvine_api_key", AsyncMock(return_value=credential) ) monkeypatch.setattr( "pullbox.providers.metadata.comicvine.ComicVineProvider", lambda **_: provider ) monkeypatch.setattr( - "pullbox.services.metadata_sources.get_comicvine_api_key", AsyncMock(return_value="test") + "pullbox.services.metadata_sources.get_comicvine_api_key", + AsyncMock(return_value=credential), ) monkeypatch.setattr( "pullbox.providers.metadata.sources.ComicVineProvider", lambda *_args, **_kw: provider ) - return provider + try: + yield provider + finally: + _run_async_blocking(_remove_created_arcs(existing)) + + +@pytest.fixture +def advance_account_clock(monkeypatch: pytest.MonkeyPatch) -> Callable[[timedelta], None]: + offset = timedelta() + clock = Mock(wraps=datetime) + clock.now.side_effect = lambda tz=UTC: datetime.now(tz) + offset + monkeypatch.setattr("pullbox.services.metadata_account_admission.datetime", clock) + + def advance(duration: timedelta) -> None: + nonlocal offset + offset += duration + + return advance def test_catalog_search_uses_standard_comicvine_loading_popup( @@ -133,7 +176,11 @@ def test_catalog_results_use_add_series_card_layout( def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( - authed_page: Page, seeded_server: str, catalog_provider: CatalogProvider + authed_page: Page, + seeded_server: str, + catalog_provider: CatalogProvider, + advance_account_clock: Callable[[timedelta], None], + monkeypatch: pytest.MonkeyPatch, ) -> None: page = authed_page errors: list[str] = [] @@ -305,15 +352,25 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( ) # Provider failures and incomplete responses must never look like no changes. + arc_reads = AsyncMock(wraps=catalog_provider.get_story_arc) + monkeypatch.setattr(catalog_provider, "get_story_arc", arc_reads) catalog_provider.fail = True page.get_by_role("link", name="Check again", exact=True).click() expect(page.get_by_role("alert")).to_contain_text("Could not finish the story arc") expect(page.get_by_text("This story arc is up to date", exact=True)).to_have_count(0) expect(page.get_by_test_id("story-arc-update-results")).to_have_count(0) + failed_reads = arc_reads.await_count + assert failed_reads > 0 catalog_provider.fail = False catalog_provider.metadata = replace(catalog_provider.metadata, membership_complete=False) + with page.expect_response(re.compile(r"/catalog-refresh$")): + page.get_by_role("link", name="Check again", exact=True).click() + expect(page.get_by_role("alert")).to_contain_text("unavailable") + assert arc_reads.await_count == failed_reads, "The durable cooldown must prevent another call" + advance_account_clock(timedelta(minutes=6)) page.get_by_role("link", name="Check again", exact=True).click() expect(page.get_by_role("alert")).to_contain_text(re.compile("incomplete", re.I)) + assert arc_reads.await_count > failed_reads, "An expired cooldown admits a recovery probe" expect(page.get_by_label("I reviewed these provider changes")).to_have_count(0) catalog_provider.metadata = replace(catalog_provider.metadata, membership_complete=True) page.get_by_role("navigation", name="Breadcrumb").get_by_role( diff --git a/tests/integration/metadata_identity/test_arc_metadata_cascade.py b/tests/integration/metadata_identity/test_arc_metadata_cascade.py new file mode 100644 index 00000000..6b1647b1 --- /dev/null +++ b/tests/integration/metadata_identity/test_arc_metadata_cascade.py @@ -0,0 +1,298 @@ +"""Real Arc commands enrich descriptions without a second membership authority.""" + +import asyncio +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from pullbox.core.metadata_identity import ( + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + MetadataEntityKind, + MetadataSource, +) +from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest +from pullbox.core.metadata_identity_state import IdentityVerificationAction +from pullbox.models import Issue, StoryArc, StoryArcExternalIdentity +from pullbox.models.metadata_baseline import StoryArcMetadataBaseline +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.story_arc import IssueStoryArc +from pullbox.providers.metadata import sources +from pullbox.schemas.metadata_arc_catalog import ArcCatalogRefresh +from pullbox.schemas.metadata_sources import MetadataFetch, SourceCapability, SourceStatus +from pullbox.services.metadata_arc_commands import ( + source_arc_add_transaction, + source_arc_refresh_transaction, +) +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_discovery import MetadataSourceError +from pullbox.services.metadata_identity_attachment import attach_verified_identities +from pullbox.services.story_arc_catalog_types import StoryArcCatalogError +from pullbox.tasks import story_arc_metadata_task as task +from tests.integration.metadata_identity.test_source_arc_commands import prepared +from tests.unit.test_metadata_discovery import registration +from tests.unit.test_metadata_story_arc_reads import ArcAdapter, arc_row +from tests.unit.test_source_arc_catalog_fetch import CatalogAdapter, fetch + +CV = MetadataSource.COMICVINE_API +KIND = MetadataEntityKind.STORY_ARC + + +async def setup(factory, tmp_path, monkeypatch, *, verified=True): + preview, decision = await prepared(factory, tmp_path) + decision.monitored = True + decision.skipped_issue_ids = ["1000"] + async with ( + factory() as session, + source_arc_add_transaction(session, preview, decision) as result, + ): + arc_id, revision = result.arc.id, result.arc.revision + async with factory.begin() as session: + session.add(MetadataSourceConfig(source=CV.value, enabled=True, priority=1, revision=1)) + if verified: + identity = ExternalIdentityRef(CV.identity_namespace, KIND, "98") + await attach_verified_identities( + session, + [ + IdentityEventRequest( + uuid4(), + arc_id, + IdentityVerificationAction.VERIFY, + IdentityEventEvidence( + ExactIdentityEvidence( + identity, IdentityEvidenceKind.PROVIDER_RESULT, CV + ), + "a" * 64, + source_identity=identity, + ), + ) + ], + ) + adapter = ArcAdapter( + CV, + result=MetadataFetch( + status=SourceStatus.OK, + data=arc_row( + CV, + external_id="98", + title="Preferred title", + description="CV description", + issue_external_ids=["901"], + declared_issue_count=1, + membership_complete=True, + ), + ), + ) + native = CatalogAdapter(2) + monkeypatch.setattr(task, "get_session_factory", lambda: factory) + monkeypatch.setattr( + "pullbox.services.metadata_sources.get_comicvine_api_key", + AsyncMock(return_value="arc-cascade-test"), + ) + monkeypatch.setattr( + sources, + "metadata_sources", + lambda: { + CV: registration(adapter, capabilities=[SourceCapability.STORY_ARC_DETAILS]), + native.source: registration(native, capabilities=list(SourceCapability)), + }, + ) + refreshed = await fetch(native) + native.calls.clear() + native.closed = 0 + refresh = ArcCatalogRefresh( + source=refreshed.source, + external_id=refreshed.metadata.provider_id, + source_revision=refreshed.source_revision, + fingerprint=refreshed.fingerprint, + expected_revision=revision, + ) + return arc_id, refreshed, refresh, adapter, native + + +async def test_manual_refresh_uses_attached_priority_without_changing_membership_source( + identity_probe_db, tmp_path, monkeypatch +): + engine, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, native = await setup(factory, tmp_path, monkeypatch) + original = adapter._call + + async def unlocked(*args): + assert engine.pool.checkedout() == 0, "Provider I/O must not retain a database transaction" + await original(*args) + + adapter._call = unlocked + async with ( + factory() as session, + source_arc_refresh_transaction(session, arc_id, preview, decision) as result, + ): + assert result.arc.description == "CV description" + assert result.arc.name == "Preferred title" + async with factory() as session: + saved = await load_metadata_baseline(session, KIND, arc_id) + assert saved.snapshot.values.description == "CV description" + assert next(o.source for o in saved.snapshot.origins if o.field == "description") is CV + members = list(await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.id))) + assert [row.source_issue_id for row in members] == ["1000", "1001"] + assert members[0].resolution_state.value == "skipped" + assert members[1].evidence["catalog_review_required"] + assert not any(row.sync_eligible for row in members) + assert set(await session.scalars(select(Issue.comicvine_id))) == {None} + assert len(adapter.calls) == 1 and adapter.closed == 1 + assert native.calls == [], "Reuse catalog evidence instead of refetching it" + + +async def test_scheduled_refresh_fills_descriptive_gaps_through_same_cascade( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, _, _, adapter, native = await setup(factory, tmp_path, monkeypatch) + await task.sync_story_arc_metadata() + async with factory() as session: + arc = await session.get(StoryArc, arc_id) + assert arc.description == "CV description" + assert arc.name == "Shared title", "Scheduled work is gap-fill, not explicit replacement" + assert len(list(await session.scalars(select(IssueStoryArc)))) == 2 + assert len(adapter.calls) == 1 + assert sum(call[0] == "arc" for call in native.calls) == 1 + + +async def test_unverified_source_never_supplies_metadata(identity_probe_db, tmp_path, monkeypatch): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup( + factory, tmp_path, monkeypatch, verified=False + ) + async with ( + factory() as session, + source_arc_refresh_transaction(session, arc_id, preview, decision) as result, + ): + assert result.arc.description is None + assert adapter.calls == [] + + +@pytest.mark.parametrize("change", ["edit", "clear", "identity", "policy", "baseline"]) +async def test_network_race_rejects_all_metadata_and_membership_writes( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup(factory, tmp_path, monkeypatch) + original = adapter._call + + async def mutate(*args): + await original(*args) + async with factory.begin() as session: + if change in {"edit", "clear"}: + await session.execute( + update(StoryArc) + .where(StoryArc.id == arc_id) + .values( + name="My title" if change == "edit" else "Shared title", + cover_url=None, + description="Keep my edit" if change == "edit" else "", + ) + ) + elif change == "identity": + await session.execute( + update(StoryArcExternalIdentity) + .where(StoryArcExternalIdentity.source == "comicvine") + .values(verification_state="stale", revision=2) + ) + elif change == "policy": + await session.execute( + update(MetadataSourceConfig) + .where(MetadataSourceConfig.source == CV.value) + .values(priority=9, revision=2) + ) + else: + await session.execute(update(StoryArcMetadataBaseline).values(revision=2)) + + adapter._call = mutate + async with factory() as session: + with pytest.raises(StoryArcCatalogError, match="changed"): + async with source_arc_refresh_transaction(session, arc_id, preview, decision): + pytest.fail("Stale descriptive reads must not update membership either") + assert not session.in_transaction() + async with factory() as session: + assert (await session.get(StoryArc, arc_id)).revision == decision.expected_revision + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +async def test_cancelled_secondary_read_closes_client_without_library_write( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup(factory, tmp_path, monkeypatch) + adapter.wait = asyncio.Event() + + async def refresh(): + async with ( + factory() as session, + source_arc_refresh_transaction(session, arc_id, preview, decision), + ): + pytest.fail("A cancelled read cannot commit") + + running = asyncio.create_task(refresh()) + try: + await asyncio.wait_for(adapter.started.wait(), 2) + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + assert adapter.closed == 1 + async with factory() as session: + assert (await session.get(StoryArc, arc_id)).revision == decision.expected_revision + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 + + +async def test_secondary_failure_keeps_safe_catalog_progress_and_records_outcome( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup(factory, tmp_path, monkeypatch) + adapter.error = MetadataSourceError(SourceStatus.RATE_LIMITED, 60) + async with ( + factory() as session, + source_arc_refresh_transaction(session, arc_id, preview, decision) as result, + ): + assert result.arc.description is None + outcomes = result.arc.diagnostics.get("metadata_refresh", {}).get("outcomes", []) + assert {"source": CV.value, "status": "rate_limited", "retry_after_seconds": 60} in outcomes + async with factory() as session: + assert len(list(await session.scalars(select(IssueStoryArc)))) == 2 + + +async def test_corrupt_baseline_returns_safe_domain_error_without_network( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup(factory, tmp_path, monkeypatch) + async with factory.begin() as session: + await session.execute(update(StoryArcMetadataBaseline).values(snapshot_json="{}")) + async with factory() as session: + with pytest.raises(StoryArcCatalogError, match="baseline"): + async with source_arc_refresh_transaction(session, arc_id, preview, decision): + pytest.fail("Invalid baseline must stop the entire refresh") + assert not session.in_transaction() + assert not adapter.calls + + +async def test_conflicting_secondary_crosswalk_stops_membership_and_metadata( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + arc_id, preview, decision, adapter, _ = await setup(factory, tmp_path, monkeypatch) + adapter.result.data.cross_identities = [ + ExternalIdentityRef(MetadataSource.METRON_API.identity_namespace, KIND, "999") + ] + async with factory() as session: + with pytest.raises(StoryArcCatalogError, match="needs review"): + async with source_arc_refresh_transaction(session, arc_id, preview, decision): + pytest.fail("Priority cannot conceal exact disagreement") + async with factory() as session: + assert (await session.get(StoryArc, arc_id)).revision == decision.expected_revision + assert len(list(await session.scalars(select(IssueStoryArc)))) == 1 diff --git a/tests/unit/test_metadata_refresh_snapshot.py b/tests/unit/test_metadata_refresh_snapshot.py index df4b64ac..6a4f1b7a 100644 --- a/tests/unit/test_metadata_refresh_snapshot.py +++ b/tests/unit/test_metadata_refresh_snapshot.py @@ -75,6 +75,30 @@ async def test_separate_domain_priority_and_gap_fill_without_repeat_fetch(): assert all(len(a.calls) == 1 and a.closed == 1 for a in adapters.values()) +async def test_fresh_catalog_evidence_is_reused_without_skipping_higher_priority(): + registry, adapters = setup() + seed = row(METRON, title="Already fetched").model_copy( + update={"description": "Catalog description"} + ) + result = await fetch( + registry, + fields=frozenset({"title", "description"}), + initial_candidates=[seed], + ) + assert result.snapshot.values.title == "Primary" + assert result.snapshot.values.description == "Catalog description" + assert adapters[CV].calls == [("series", "42", None)] + assert not adapters[METRON].calls + assert {outcome.status for outcome in result.outcomes} == {SourceStatus.OK} + + +async def test_seeded_evidence_cannot_bypass_exact_identity_validation(): + registry, adapters = setup() + with pytest.raises(MetadataAssemblyError, match="verified exact identity"): + await fetch(registry, initial_candidates=[row(METRON, external_id="99")]) + assert not any(adapter.calls for adapter in adapters.values()) + + async def test_failure_keeps_typed_outcome_and_falls_back_without_changing_user_value(): registry, adapters = setup() adapters[CV].error = MetadataSourceError(SourceStatus.RATE_LIMITED, 60) From 0a213b3c18ad78449139126108f478d209f31da0 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 19:01:42 -0700 Subject: [PATCH 56/97] fix(metadata): unify legacy story arc source controls Route older catalog URLs through source-bound commands so disabled sources and stale policy revisions cannot bypass review. Preserve canonical refresh, membership choices, private covers and actionable settings errors. Fail closed on successful reads with failed provider cleanup while preserving cancellation and rate limits. Update migrated fixtures and browser URL expectations without weakening behavioral assertions. --- docs/development/ARCHITECTURE_OVERVIEW.md | 13 +- src/pullbox/services/metadata_source_reads.py | 18 +- src/pullbox/ui/metadata_arc_search.py | 11 +- src/pullbox/ui/story_arc_catalog_forms.py | 1 + src/pullbox/ui/story_arc_catalog_routes.py | 394 +++--------------- src/pullbox/ui/story_arc_source_routes.py | 42 +- tests/e2e/test_story_arc_catalog_page.py | 30 +- tests/ui/test_legacy_story_arc_sources.py | 197 +++++++++ tests/ui/test_story_arc_catalog_ui.py | 46 +- tests/unit/test_metadata_story_arc_reads.py | 24 ++ 10 files changed, 400 insertions(+), 376 deletions(-) create mode 100644 tests/ui/test_legacy_story_arc_sources.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 777e5f68..1007b19e 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -430,10 +430,15 @@ fields without taking over membership. The read set captures values, baseline, identity revisions/history, arc state, placement restrictions and source policies. Provider I/O runs after releasing the reader. Policy-first/arc locks and read-set revalidation precede the atomic catalog write; concurrent changes reject the whole -refresh. Partial source outcomes are retained as bounded diagnostics. New Add and -legacy preview compatibility still use their selected source; remaining legacy -consumer migration, complete credits/membership fields and coordinated XML/sidecar -writers are pending. This does not complete the full metadata feature. +refresh. Partial source outcomes are retained as bounded diagnostics. Legacy Arc +browser URLs now delegate to the same source-bound search, preview, Add and refresh +commands. Pre-upgrade forms without a source revision must be previewed again; +existing ComicVine arcs retain their identity and reviewed membership. New Add +adopts the selected source, while refresh uses the descriptive cascade. Provider +cleanup failure cannot supply a successful read for a write; primary cancellation +and rate-limit outcomes remain intact. Remaining legacy consumer migration, +complete credits/membership fields and coordinated XML/sidecar writers are pending. +This does not complete the full metadata feature. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py index 429bbf80..7afbc9d0 100644 --- a/src/pullbox/services/metadata_source_reads.py +++ b/src/pullbox/services/metadata_source_reads.py @@ -216,6 +216,7 @@ async def _read_admitted[T]( attempt: AccountAttempt, ) -> MetadataFetch[T]: adapter = None + close_failed = False try: if asyncio.get_running_loop().time() >= deadline: return MetadataFetch(status=SourceStatus.TIMEOUT) @@ -234,16 +235,15 @@ async def _read_admitted[T]( or (result.status is SourceStatus.NOT_MODIFIED and validator is None) ): raise ValueError("Invalid source read outcome") - return result except TimeoutError: - return MetadataFetch(status=SourceStatus.TIMEOUT) + result = MetadataFetch(status=SourceStatus.TIMEOUT) except MetadataSourceError as exc: - return MetadataFetch(status=exc.status, retry_after_seconds=exc.retry_after_seconds) + result = MetadataFetch(status=exc.status, retry_after_seconds=exc.retry_after_seconds) except (ValueError, TypeError, AttributeError): - return MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE) + result = MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE) except Exception: logger.warning("metadata_source_read_failed", source=source.value, capability=capability) - return MetadataFetch(status=SourceStatus.UNAVAILABLE) + result = MetadataFetch(status=SourceStatus.UNAVAILABLE) finally: if adapter is not None: try: @@ -251,6 +251,14 @@ async def _read_admitted[T]( await adapter.close() except Exception: logger.warning("metadata_source_close_failed", source=source.value) + close_failed = True + if close_failed and result.status in { + SourceStatus.OK, + SourceStatus.NOT_FOUND, + SourceStatus.NOT_MODIFIED, + }: + return MetadataFetch(status=SourceStatus.UNAVAILABLE) + return result async def read_series( diff --git a/src/pullbox/ui/metadata_arc_search.py b/src/pullbox/ui/metadata_arc_search.py index 91685313..9a7624bf 100644 --- a/src/pullbox/ui/metadata_arc_search.py +++ b/src/pullbox/ui/metadata_arc_search.py @@ -9,6 +9,7 @@ from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource from pullbox.schemas.metadata_sources import ( SourceCapability, + SourceStatus, StoryArcDiscoveryQuery, StoryArcDiscoveryRead, ) @@ -50,6 +51,10 @@ async def arc_search_context( ) except MetadataSearchBusyError as exc: error = str(exc) + if not snapshot.results and any( + row.status not in {SourceStatus.OK, SourceStatus.EMPTY} for row in snapshot.sources + ): + error = "Story Arc search failed. Check the source status below and retry." total = len(snapshot.results) total_pages = max(1, (total + 19) // 20) page = min(page, total_pages) @@ -63,10 +68,8 @@ async def arc_search_context( transport, ) owners.update({(transport.identity_namespace, key): value for key, value in found.items()}) - revisions = {row.policy.source: row.policy.revision for row in runtime} results = [] for row in visible: - native = revisions[row.source] > 0 or row.source is not MetadataSource.COMICVINE_API results.append( { "metadata": { @@ -79,9 +82,7 @@ async def arc_search_context( "existing_id": owners.get((row.identity_namespace, row.external_id)), "source_label": SOURCE_LABELS[row.source], "dom_id": f"arc-result-{row.source.value}-{row.external_id}", - "preview_url": f"/story-arcs/catalog/{row.source.value}/{row.external_id}" - if native - else f"/story-arcs/catalog/{row.external_id}", + "preview_url": f"/story-arcs/catalog/{row.source.value}/{row.external_id}", } ) params = {"q": query, **({"source": source.value} if source else {})} diff --git a/src/pullbox/ui/story_arc_catalog_forms.py b/src/pullbox/ui/story_arc_catalog_forms.py index a95bd8f1..3c3762cb 100644 --- a/src/pullbox/ui/story_arc_catalog_forms.py +++ b/src/pullbox/ui/story_arc_catalog_forms.py @@ -9,6 +9,7 @@ class StoryArcCatalogAddForm(BaseModel): """Validate the submitted order, canonical root and current file defaults.""" fingerprint: str = Field(max_length=128) + source_revision: int | None = Field(default=None, ge=1, lt=2**63) file_defaults_fingerprint: str = Field(default="", max_length=128) issue_provider_ids: list[str] = Field(default_factory=list, max_length=2000) reading_orders: list[int] = Field(default_factory=list, max_length=2000) diff --git a/src/pullbox/ui/story_arc_catalog_routes.py b/src/pullbox/ui/story_arc_catalog_routes.py index 90ab6a24..e81aaabc 100644 --- a/src/pullbox/ui/story_arc_catalog_routes.py +++ b/src/pullbox/ui/story_arc_catalog_routes.py @@ -1,83 +1,35 @@ -"""Preview-first Comic Vine arc discovery and explicit membership refresh.""" +"""Compatibility URLs and shared presentation for source-bound Story Arc commands.""" from __future__ import annotations -from contextlib import asynccontextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated -from urllib.parse import urlencode -import structlog from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Path, Query, Request from fastapi.responses import RedirectResponse -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError +from pydantic import ValidationError from starlette.responses import Response -from pullbox.api.deps import ( - AuthenticatedUser, - DbSession, - get_request_session_factory, -) +from pullbox.api.deps import AuthenticatedUser, DbSession # noqa: TC001 from pullbox.core.issue_numbers import format_issue_number -from pullbox.core.library_policy import load_search_on_add_default +from pullbox.core.metadata_identity import MetadataSource from pullbox.models.story_arc import StoryArc, StoryArcLifecycle -from pullbox.providers.metadata.comicvine import ComicVineError -from pullbox.services.cover_url_service import build_story_arc_cover_url +from pullbox.schemas.metadata_sources import StoryArcPreviewQuery from pullbox.services.metadata_arc_commands import saved_arc_source -from pullbox.services.story_arc_file_defaults import load_story_arc_file_defaults -from pullbox.services.story_arc_placement_integration import StoryArcPlacementIntegrationError -from pullbox.services.story_arc_service import StoryArcServiceError, StoryArcValidationError -from pullbox.ui.comicvine_provider import ( - ComicVineNotConfiguredError, - open_comicvine_ui_provider, -) +from pullbox.ui.metadata_arc_search import arc_search_context from pullbox.ui.story_arc_catalog_forms import StoryArcCatalogAddForm # noqa: TC001 -from pullbox.ui.story_arc_presenters import load_story_arc_placement_roots if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable + from collections.abc import Callable from fastapi.templating import Jinja2Templates - from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - from pullbox.services.story_arc_catalog import ( - StoryArcCatalogPreview, - StoryArcCatalogService, - ) + from pullbox.services.story_arc_catalog import StoryArcCatalogPreview -logger = structlog.get_logger(__name__) router = APIRouter() _get_templates: Callable[[], Jinja2Templates] | None = None _build_context: Callable[..., dict[str, object]] | None = None _ProviderId = Annotated[str, Path(pattern=r"^[1-9][0-9]{0,18}$")] -_ERRORS = { - "provider": "Comic Vine couldn't load this arc. Check the provider settings and try again.", - "validation": ( - "The arc wasn't added. Review the reading order and storage choices, then confirm again." - ), - "stale": ( - "The provider membership changed after preview. Review the latest list before confirming." - ), - "conflict": "This arc changed in another tab. Review the latest changes before confirming.", - "file-defaults": ( - "Story Arc file defaults changed or are unavailable. " - "Review Settings → Media Management, then preview this arc again." - ), - "catalog_limit_exceeded": ( - "This arc exceeds the supported review limit. Nothing was added or changed." - ), - "incomplete_hydration": ( - "Some member details couldn't be loaded. Nothing was added or changed; retry preview." - ), - "canonical_root_required": ( - "Choose a library root for new series before saving provider changes." - ), - "canonical_root_unavailable": ( - "Restore or enable the saved library root in Settings, then retry provider changes. " - "Existing series paths and arc storage haven't changed." - ), -} @dataclass(frozen=True) @@ -102,96 +54,11 @@ def _render(request: Request, username: str, template: str, **values: object) -> def _redirect(request: Request, url: str) -> Response: if request.headers.get("HX-Request"): return Response(status_code=204, headers={"HX-Redirect": url}) - # All callers build /story-arcs routes from integer IDs or the numeric - # _ProviderId validator; no request value supplies an origin or URL prefix. + # Callers build fixed /story-arcs routes from validated IDs/source enums. # codeql[py/url-redirection] return RedirectResponse(url, status_code=303) -@asynccontextmanager -async def _catalog_service( - session: DbSession, - *, - session_factory: async_sessionmaker[AsyncSession] | None = None, -) -> AsyncIterator[StoryArcCatalogService]: - from pullbox.services.story_arc_catalog import StoryArcCatalogService - - try: - async with open_comicvine_ui_provider( - session, - session_factory=session_factory, - ) as provider: - yield StoryArcCatalogService(provider) - except ComicVineNotConfiguredError as exc: - raise StoryArcValidationError("Comic Vine is not configured") from exc - - -def _failure_code(exc: Exception) -> str: - code = getattr(exc, "code", "") - if code in ("catalog_limit_exceeded", "incomplete_hydration"): - return str(code) - return "provider" - - -async def _existing(session: DbSession, provider_id: str) -> int | None: - if int(provider_id) > 2**63 - 1: - raise HTTPException(status_code=404, detail="Story Arc provider identity not found") - existing_id = await session.scalar( - select(StoryArc.id).where(StoryArc.comicvine_id == int(provider_id)) - ) - return int(existing_id) if existing_id is not None else None - - -async def load_story_arc_catalog_search_context( - session: DbSession, - *, - q: str, - page: int, - base_url: str, - session_factory: async_sessionmaker[AsyncSession] | None = None, -) -> dict[str, object]: - """Load one bounded Comic Vine arc result page for full or HTMX shells.""" - query = q.strip() - results: list[dict[str, object]] = [] - total = 0 - error = "" - if len(query) >= 2: - try: - async with _catalog_service(session, session_factory=session_factory) as service: - found, total = await service.search(query, limit=20, offset=(page - 1) * 20) - existing = await service.find_existing( - session, [item.provider_id for item in found] - ) - results = [ - {"metadata": item, "existing_id": existing.get(item.provider_id)} - for item in found - ] - except (ComicVineError, StoryArcServiceError): - await session.rollback() - error = "Comic Vine search failed. Check the provider settings and try again." - logger.warning("story_arc_catalog_search_failed") - - total_pages = max(1, (total + 19) // 20) - return { - "query": query, - "catalog_query": query, - "results": results, - "total": total, - "page": page, - "total_pages": total_pages, - "shown_count": len(results), - "in_library_count": sum(1 for result in results if result["existing_id"]), - "error_message": error, - "pagination_base_url": (f"{base_url}?{urlencode({'q': query})}" if query else base_url), - "next_url": ( - base_url + "?" + urlencode({"q": query, "page": page + 1}) if page * 20 < total else "" - ), - "previous_url": ( - base_url + "?" + urlencode({"q": query, "page": page - 1}) if page > 1 else "" - ), - } - - def _members(preview: StoryArcCatalogPreview) -> list[dict[str, str]]: titles = {series.provider_id: series.title for series in preview.series} return [ @@ -212,16 +79,17 @@ async def story_arc_catalog_search( request: Request, user: AuthenticatedUser, session: DbSession, - q: Annotated[str, Query(max_length=500)] = "", + q: Annotated[str, Query(max_length=300)] = "", page: Annotated[int, Query(ge=1, le=100)] = 1, ) -> Response: username = user.username - context = await load_story_arc_catalog_search_context( + context = await arc_search_context( + request, session, q=q, page=page, + source=MetadataSource.COMICVINE_API, base_url="/story-arcs/catalog", - session_factory=get_request_session_factory(request), ) return _render( request, @@ -241,36 +109,12 @@ async def story_arc_catalog_preview( session: DbSession, error: str = Query(""), ) -> Response: - username = user.username - existing_id = await _existing(session, provider_id) - if existing_id is not None: - return _redirect(request, f"/story-arcs/{existing_id}") - preview = None - message = _ERRORS.get(error, "") - try: - async with _catalog_service( - session, - session_factory=get_request_session_factory(request), - ) as service: - preview = await service.preview(provider_id) - except (ComicVineError, StoryArcServiceError) as exc: - await session.rollback() - message = _ERRORS[_failure_code(exc)] - logger.warning("story_arc_catalog_preview_failed", category=_failure_code(exc)) - roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None) - managed_roots = tuple(root for root in roots if root.can_manage) - return _render( - request, - username, - "pages/story_arc_catalog_preview.html", - preview=preview, - members=_members(preview) if preview else [], - provider_id=provider_id, - error_message=message, - placement_roots=roots, - managed_roots=managed_roots, - placement_roots_truncated=truncated, - arc_file_defaults=await load_story_arc_file_defaults(session), + from pullbox.ui.story_arc_source_routes import source_arc_preview + + if int(provider_id) > 2**63 - 1: + raise HTTPException(status_code=404, detail="Story Arc provider identity not found") + return await source_arc_preview( + MetadataSource.COMICVINE_API, provider_id, request, user, session, error ) @@ -283,72 +127,40 @@ async def story_arc_catalog_add( background_tasks: BackgroundTasks, form: Annotated[StoryArcCatalogAddForm, Form()], ) -> Response: - existing_id = await _existing(session, provider_id) - if existing_id is not None: - return _redirect(request, f"/story-arcs/{existing_id}") - code = "validation" + from pullbox.ui.story_arc_source_routes import SourceArcAddForm, source_arc_add + + if int(provider_id) > 2**63 - 1: + raise HTTPException(status_code=404, detail="Story Arc provider identity not found") try: - order = form.reviewed_order() - defaults = await load_story_arc_file_defaults(session) - if ( - form.file_defaults_fingerprint - and form.file_defaults_fingerprint != defaults.fingerprint - ): - code = "file-defaults" - raise StoryArcValidationError("File defaults changed") - policy = defaults.proposal() - async with _catalog_service( - session, - session_factory=get_request_session_factory(request), - ) as service: - preview = await service.preview(provider_id) - if preview.fingerprint != form.fingerprint: - code = "stale" - raise StoryArcValidationError("Preview changed") - arc = await service.add( - session, - preview, - ordered_issue_provider_ids=order, - skipped_issue_provider_ids=form.skipped_issue_provider_ids, - library_root_id=form.library_root_id, - monitored=form.monitored, - search_missing=form.monitored, - include_upcoming=form.monitored, - placement_policy=policy, - ) - arc_id = arc.id - initial_pending = _has_initial_work(arc) - search_on_add = arc.monitored and await load_search_on_add_default(session) - await session.commit() - except StoryArcPlacementIntegrationError: - await session.rollback() - return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=file-defaults") - except (StoryArcServiceError, IntegrityError): - await session.rollback() - return _redirect(request, f"/story-arcs/catalog/{provider_id}?error={code}") - except ComicVineError: - await session.rollback() - return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=provider") - if search_on_add: - from pullbox.tasks.story_arc_search_task import schedule_story_arc_search - - schedule_story_arc_search(arc_id) - if initial_pending: - from pullbox.services.story_arc_catalog_placement import run_catalog_initial_placements - - background_tasks.add_task( - run_catalog_initial_placements, - arc_id, - session_factory=request.app.state.db_session_factory, + decision = SourceArcAddForm.model_validate(form.model_dump()) + except ValidationError: + # A pre-upgrade form has no source revision and must not bypass review. + return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=review") + return await source_arc_add( + MetadataSource.COMICVINE_API, + provider_id, + request, + _user, + session, + background_tasks, + decision, + ) + + +def _refresh_selection(arc: StoryArc) -> StoryArcPreviewQuery | None: + selected = saved_arc_source(arc) + if selected is None and arc.comicvine_id is not None: + selected = StoryArcPreviewQuery( + source=MetadataSource.COMICVINE_API, external_id=str(arc.comicvine_id) ) - return _redirect(request, f"/story-arcs/{arc_id}?notice=catalog-added") + return selected async def _provider_arc(session: DbSession, arc_id: int) -> StoryArc: arc = await session.get(StoryArc, arc_id) if ( arc is None - or (arc.comicvine_id is None and saved_arc_source(arc) is None) + or _refresh_selection(arc) is None or arc.lifecycle is not StoryArcLifecycle.ACTIVE ): raise HTTPException(status_code=404, detail="Active provider Story Arc not found") @@ -394,53 +206,13 @@ async def story_arc_catalog_refresh_preview( session: DbSession, error: str = Query(""), ) -> Response: + from pullbox.ui.story_arc_source_routes import source_refresh_preview + username = user.username arc = await _provider_arc(session, story_arc_id) - selection = saved_arc_source(arc) - if selection is not None: - from pullbox.ui.story_arc_source_routes import source_refresh_preview - - return await source_refresh_preview(arc, selection, request, username, session, error) - name, provider_id = arc.name, str(arc.comicvine_id) - cover_src = build_story_arc_cover_url(arc) - comicvine_url = arc.comicvine_url or ( - f"https://comicvine.gamespot.com/story-arc/4045-{provider_id}/" - ) - catalog = (arc.diagnostics or {}).get("provider_catalog") - saved_root = catalog.get("canonical_library_root_id") if isinstance(catalog, dict) else None - needs_library_root = not (type(saved_root) is int and saved_root > 0) - preview = None - changes = None - message = _ERRORS.get(error, "") - try: - async with _catalog_service( - session, - session_factory=get_request_session_factory(request), - ) as service: - preview = await service.preview(provider_id) - if preview.membership_complete: - changes = await service.preview_refresh(session, story_arc_id, preview) - except (ComicVineError, StoryArcServiceError) as exc: - await session.rollback() - message = _ERRORS[_failure_code(exc)] - roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None) - managed_roots = tuple(root for root in roots if root.can_manage) - return _render( - request, - username, - "pages/story_arc_catalog_refresh.html", - story_arc_id=story_arc_id, - arc_name=name, - arc_cover_src=cover_src, - arc_comicvine_url=comicvine_url, - preview=preview, - changes=changes, - error_message=message, - members=_members(preview) if preview else [], - needs_library_root=needs_library_root, - placement_roots=managed_roots, - placement_roots_truncated=truncated, - ) + selection = _refresh_selection(arc) + assert selection is not None + return await source_refresh_preview(arc, selection, request, username, session, error) @router.post("/story-arcs/{story_arc_id}/catalog-refresh", include_in_schema=False) @@ -455,55 +227,19 @@ async def story_arc_catalog_refresh( library_root_id: Annotated[int | None, Form(ge=1)] = None, source_revision: Annotated[int | None, Form(ge=1)] = None, ) -> Response: + from pullbox.ui.story_arc_source_routes import source_refresh + arc = await _provider_arc(session, story_arc_id) - selection = saved_arc_source(arc) - if selection is not None: - from pullbox.ui.story_arc_source_routes import source_refresh - - return await source_refresh( - story_arc_id, - selection, - request, - session, - source_revision=source_revision, - expected_revision=expected_revision, - fingerprint=fingerprint, - confirm_refresh=confirm_refresh, - library_root_id=library_root_id, - ) - provider_id = str(arc.comicvine_id) - code = "conflict" - try: - if not confirm_refresh: - raise StoryArcValidationError("Confirm the refresh preview") - async with _catalog_service( - session, - session_factory=get_request_session_factory(request), - ) as service: - preview = await service.preview(provider_id) - if preview.fingerprint != fingerprint: - code = "stale" - raise StoryArcValidationError("Preview changed") - result = await service.refresh( - session, - story_arc_id, - preview, - expected_revision=expected_revision, - library_root_id=library_root_id, - ) - search_on_add = result.story_arc.monitored and await load_search_on_add_default(session) - await session.commit() - except (StoryArcServiceError, IntegrityError) as exc: - await session.rollback() - root_error = getattr(exc, "code", "") - if root_error in {"canonical_root_required", "canonical_root_unavailable"}: - code = root_error - return _redirect(request, f"/story-arcs/{story_arc_id}/catalog-refresh?error={code}") - except ComicVineError: - await session.rollback() - return _redirect(request, f"/story-arcs/{story_arc_id}/catalog-refresh?error=provider") - if search_on_add: - from pullbox.tasks.story_arc_search_task import schedule_story_arc_search - - schedule_story_arc_search(story_arc_id) - return _redirect(request, f"/story-arcs/{story_arc_id}?notice=catalog-refreshed") + selection = _refresh_selection(arc) + assert selection is not None + return await source_refresh( + story_arc_id, + selection, + request, + session, + source_revision=source_revision, + expected_revision=expected_revision, + fingerprint=fingerprint, + confirm_refresh=confirm_refresh, + library_root_id=library_root_id, + ) diff --git a/src/pullbox/ui/story_arc_source_routes.py b/src/pullbox/ui/story_arc_source_routes.py index aeb6f1f2..0ccf1b70 100644 --- a/src/pullbox/ui/story_arc_source_routes.py +++ b/src/pullbox/ui/story_arc_source_routes.py @@ -18,6 +18,7 @@ ArcCatalogSelection, ) from pullbox.schemas.metadata_sources import StoryArcPreviewQuery +from pullbox.services.cover_url_service import build_story_arc_cover_url from pullbox.services.metadata_arc_commands import ( ArcCommandResult, catalog_writer, @@ -46,9 +47,26 @@ class SourceArcAddForm(StoryArcCatalogAddForm): source_revision: int = Field(ge=1, lt=2**63) -def command_message(exc: Exception | None = None) -> str: +def command_message(exc: Exception | None = None, *, code: str = "") -> str: if isinstance(exc, StoryArcCatalogError): + if exc.code == "incomplete_membership": + return "Incomplete member list. Nothing was changed; retry the preview." return str(exc) + messages = { + "file_defaults_changed": ( + "Story Arc file defaults changed. Review Settings > Media Management, " + "then preview this arc again." + ), + "canonical_root_required": ( + "Choose a library root for new series before saving provider changes." + ), + "canonical_root_unavailable": ( + "Restore or enable the saved library root in Settings, then retry provider changes. " + "Existing series paths and arc storage haven't changed." + ), + } + if code in messages: + return messages[code] return ( "The arc wasn't changed. Review the current source and file settings, then retry preview." ) @@ -95,7 +113,7 @@ async def source_arc_preview( ) -> Response: username = user.username preview = None - message = command_message() if error else "" + message = command_message(code=error) if error else "" revision = 0 try: selection = await current_selection(session, source, provider_id) @@ -163,9 +181,13 @@ async def source_arc_add( StoryArcPlacementIntegrationError, IntegrityError, ValidationError, - ): + ) as exc: await session.rollback() - return _redirect(request, f"/story-arcs/catalog/{source.value}/{provider_id}?error=review") + code = exc.code if isinstance(exc, StoryArcCatalogError) else "review" + return _redirect( + request, + f"/story-arcs/catalog/{source.value}/{provider_id}?{urlencode({'error': code})}", + ) schedule_work(result, request, background_tasks) return _redirect(request, destination) @@ -179,10 +201,16 @@ async def source_refresh_preview( error: str, ) -> Response: arc_id, name = arc.id, arc.name + cover_src = build_story_arc_cover_url(arc) + resource_url = ( + f"https://comicvine.gamespot.com/story-arc/4045-{selection.external_id}/" + if selection.source is MetadataSource.COMICVINE_API + else None + ) catalog = arc.diagnostics.get("provider_catalog", {}) root = catalog.get("canonical_library_root_id") if isinstance(catalog, dict) else None preview = changes = None - message = command_message() if error else "" + message = command_message(code=error) if error else "" revision = 0 try: current = await current_selection(session, selection.source, selection.external_id) @@ -201,8 +229,8 @@ async def source_refresh_preview( "pages/story_arc_catalog_refresh.html", story_arc_id=arc_id, arc_name=name, - arc_cover_src=None, - arc_comicvine_url=None, + arc_cover_src=cover_src, + arc_comicvine_url=resource_url, source_label=SOURCE_LABELS[selection.source], source_revision=revision, preview=preview, diff --git a/tests/e2e/test_story_arc_catalog_page.py b/tests/e2e/test_story_arc_catalog_page.py index 6b282ade..6ec94bdd 100644 --- a/tests/e2e/test_story_arc_catalog_page.py +++ b/tests/e2e/test_story_arc_catalog_page.py @@ -463,8 +463,8 @@ def test_catalog_add_is_enabled_and_explains_missing_root( page.get_by_role("option").nth(1).click() expect(error).not_to_be_visible() held: list[Route] = [] - page.route("**/story-arcs/catalog/80", lambda route: held.append(route)) - with page.expect_request("**/story-arcs/catalog/80"): + page.route("**/story-arcs/catalog/comicvine_api/80", lambda route: held.append(route)) + with page.expect_request("**/story-arcs/catalog/comicvine_api/80"): add.click() expect(add).to_be_disabled() expect(page.get_by_role("button", name="Retry preview")).to_be_disabled() @@ -481,7 +481,7 @@ def test_catalog_add_explains_no_available_roots( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( - "pullbox.ui.story_arc_catalog_routes.load_story_arc_placement_roots", + "pullbox.ui.story_arc_source_routes.load_story_arc_placement_roots", AsyncMock(return_value=((), False)), ) page = authed_page @@ -497,7 +497,10 @@ def test_catalog_add_explains_no_available_roots( def test_catalog_partial_failure_and_retry_are_visible( - authed_page: Page, seeded_server: str, catalog_provider: CatalogProvider + authed_page: Page, + seeded_server: str, + catalog_provider: CatalogProvider, + advance_account_clock: Callable[[timedelta], None], ) -> None: page = authed_page catalog_provider.metadata = replace( @@ -520,11 +523,12 @@ def test_catalog_partial_failure_and_retry_are_visible( catalog_provider.fail = True page.get_by_role("button", name="Retry preview").click() expect(page.get_by_test_id("story-arc-preview-submit-error")).not_to_be_visible() - expect(page.get_by_role("alert")).to_contain_text("couldn't load this arc") + expect(page.get_by_role("alert")).to_contain_text("Could not finish the story arc") catalog_provider.fail = False catalog_provider.metadata = replace( catalog_provider.metadata, membership_complete=True, declared_issue_count=2 ) + advance_account_clock(timedelta(minutes=6)) page.get_by_role("button", name="Retry preview").click() expect(page.get_by_test_id("story-arc-catalog-add-form")).to_be_visible() page.set_viewport_size({"width": 640, "height": 900}) @@ -535,7 +539,10 @@ def test_catalog_partial_failure_and_retry_are_visible( def test_preview_pagination_retry_and_add_keep_all_page_choices( - authed_page: Page, seeded_server: str, catalog_provider: CatalogProvider + authed_page: Page, + seeded_server: str, + catalog_provider: CatalogProvider, + advance_account_clock: Callable[[timedelta], None], ) -> None: page = authed_page catalog_provider.metadata = replace( @@ -603,7 +610,7 @@ def test_preview_pagination_retry_and_add_keep_all_page_choices( expect(page.get_by_role("switch", name="Monitor this story arc")).to_be_checked() catalog_provider.fail = True page.get_by_role("button", name="Retry preview").click() - expect(page.get_by_role("alert")).to_contain_text("couldn't load this arc") + expect(page.get_by_role("alert")).to_contain_text("Could not finish the story arc") expect(first.locator("[data-reading-position]")).to_have_text("2") expect(page.get_by_role("button", name="Add Story Arc", exact=True)).to_be_enabled() catalog_provider.fail = False @@ -611,6 +618,7 @@ def test_preview_pagination_retry_and_add_keep_all_page_choices( catalog_provider.metadata, issue_provider_ids=tuple(str(number) for number in range(101, 153) if number != 103), ) + advance_account_clock(timedelta(minutes=6)) page.get_by_role("button", name="Retry preview").click() expect(workspace.get_by_role("status")).to_contain_text("1 added, 1 no longer listed") expect(first.locator("[data-reading-position]")).to_have_text("2") @@ -632,7 +640,9 @@ def test_preview_pagination_retry_and_add_keep_all_page_choices( expect(page.get_by_test_id("page-dock-pagination")).not_to_be_visible() page.get_by_role("switch", name="Monitor this story arc").press("Space") with page.expect_request( - lambda request: request.method == "POST" and "/story-arcs/catalog/77" in request.url + lambda request: ( + request.method == "POST" and "/story-arcs/catalog/comicvine_api/77" in request.url + ) ) as submitted: page.get_by_role("button", name="Add Story Arc", exact=True).click() page.wait_for_url(re.compile(r"/story-arcs/\d+\?notice=catalog-added$")) @@ -768,8 +778,8 @@ def test_preview_reorder_single_member_and_pending_retry_are_disabled( expect(controls).to_have_count(4) expect(controls.nth(1)).to_be_enabled() held: list[Route] = [] - page.route("**/story-arcs/catalog/79", lambda route: held.append(route)) - with page.expect_request("**/story-arcs/catalog/79"): + page.route("**/story-arcs/catalog/comicvine_api/79", lambda route: held.append(route)) + with page.expect_request("**/story-arcs/catalog/comicvine_api/79"): retry.click() expect(retry).to_be_disabled() for index in range(4): diff --git a/tests/ui/test_legacy_story_arc_sources.py b/tests/ui/test_legacy_story_arc_sources.py new file mode 100644 index 00000000..ca5a6186 --- /dev/null +++ b/tests/ui/test_legacy_story_arc_sources.py @@ -0,0 +1,197 @@ +"""Legacy browser URLs use the same source-bound commands as newly added arcs.""" + +import json +import re +from dataclasses import replace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import func, select, update + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models import StoryArc +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.story_arc import IssueStoryArc +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.story_arc_catalog import StoryArcCatalogService +from tests.ui.test_story_arc_catalog_ui import _csrf, _root +from tests.ui.test_story_arc_catalog_ui import catalog_provider as catalog_provider + +pytest_plugins = ["conftest_security"] + + +@pytest.fixture +async def migrated_catalog(sec_db, catalog_provider, monkeypatch): + reads = AsyncMock(wraps=catalog_provider.get_story_arc) + monkeypatch.setattr(catalog_provider, "get_story_arc", reads) + return catalog_provider, reads + + +def preview_data(html): + match = re.search(r"]*data-preview-data>(.*?)", html, re.S) + assert match, html[:300] + return json.loads(match[1]) + + +async def test_legacy_preview_and_add_save_canonical_metadata( + authenticated_client, sec_db, migrated_catalog, tmp_path +): + client = authenticated_client + root_id = await _root(sec_db, str(tmp_path)) + preview = await client.get("/story-arcs/catalog/42") + data = preview_data(preview.text) + assert data["sourceRevision"] == 1 + assert data["ready"] and data["sourceLabel"] == "ComicVine API" + response = await client.post( + "/story-arcs/catalog/42", + data={ + "source_revision": data["sourceRevision"], + "fingerprint": data["fingerprint"], + "file_defaults_fingerprint": data["fileDefaultsFingerprint"], + "library_root_id": root_id, + "issue_provider_ids": ["102", "101"], + "reading_orders": ["1", "2"], + "skipped_issue_provider_ids": ["101"], + }, + headers=_csrf(client), + follow_redirects=False, + ) + assert response.status_code == 303 and "catalog-added" in response.headers["location"] + async with sec_db() as session: + arc = await session.scalar(select(StoryArc)) + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc.id) + assert saved is not None and saved.snapshot.values.description == arc.description + assert arc.diagnostics["provider_catalog"]["snapshot"]["source"] == "comicvine_api" + members = list( + await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.sequence_number)) + ) + assert [row.source_issue_id for row in members] == ["102", "101"] + assert members[1].resolution_state.value == "skipped" + assert not list(tmp_path.iterdir()) + + +async def test_pre_v2_arc_refresh_uses_canonical_cascade_without_recreating_arc( + authenticated_client, sec_db, migrated_catalog, tmp_path +): + provider, _ = migrated_catalog + root_id = await _root(sec_db, str(tmp_path)) + service = StoryArcCatalogService(provider) + original = await service.preview("42") + async with sec_db() as session: + arc = await service.add( + session, + original, + ordered_issue_provider_ids=["102", "101"], + skipped_issue_provider_ids=["101"], + library_root_id=root_id, + ) + await session.commit() + arc_id = arc.id + arc.cover_url = "https://comicvine.gamespot.com/a/uploads/story-arcs/42.jpg" + await session.commit() + provider.metadata = replace(provider.metadata, issue_provider_ids=("101", "103")) + url = f"/story-arcs/{arc_id}/catalog-refresh" + page = await authenticated_client.get(url) + assert 'name="source_revision" value="1"' in page.text + assert re.search(rf'src="/api/v1/story-arcs/{arc_id}/cover\?v=[a-f0-9]{{12}}"', page.text) + assert 'href="https://comicvine.gamespot.com/story-arc/4045-42/"' in page.text + values = { + key: re.search(rf'name="{key}" value="([^"]+)"', page.text)[1] + for key in ("source_revision", "expected_revision", "fingerprint") + } + response = await authenticated_client.post( + url, + data={**values, "confirm_refresh": "true"}, + headers=_csrf(authenticated_client), + follow_redirects=False, + ) + assert response.status_code == 303 and "catalog-refreshed" in response.headers["location"] + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 1 + arc = await session.get(StoryArc, arc_id) + saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id) + assert saved is not None + assert "metadata_refresh" in arc.diagnostics + rows = list( + await session.scalars(select(IssueStoryArc).order_by(IssueStoryArc.sequence_number)) + ) + assert [row.source_issue_id for row in rows] == ["102", "101", "103"] + assert rows[1].resolution_state.value == "skipped" + assert rows[2].evidence["catalog_review_required"] + + +async def test_legacy_preview_honors_disabled_source_without_calls( + authenticated_client, sec_db, migrated_catalog +): + _, reads = migrated_catalog + async with sec_db.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False, revision=2)) + page = await authenticated_client.get("/story-arcs/catalog/42") + assert page.status_code == 200 and not preview_data(page.text)["ready"] + reads.assert_not_awaited() + assert "Enable and save this source" in page.text + + +async def test_legacy_stale_form_cannot_bypass_source_revision( + authenticated_client, sec_db, migrated_catalog, tmp_path +): + _, reads = migrated_catalog + root_id = await _root(sec_db, str(tmp_path)) + preview = await authenticated_client.get("/story-arcs/catalog/42") + data = preview_data(preview.text) + async with sec_db.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False, revision=2)) + reads.reset_mock() + response = await authenticated_client.post( + "/story-arcs/catalog/42", + data={ + "fingerprint": data["fingerprint"], + "source_revision": "1", + "file_defaults_fingerprint": data["fileDefaultsFingerprint"], + "library_root_id": root_id, + "issue_provider_ids": ["101", "102"], + "reading_orders": ["1", "2"], + }, + headers=_csrf(authenticated_client), + follow_redirects=False, + ) + assert response.status_code == 303 and "error=" in response.headers["location"] + reads.assert_not_awaited() + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 0 + + +async def test_pre_upgrade_form_requires_new_preview_without_provider_calls( + authenticated_client, sec_db, migrated_catalog, tmp_path +): + _, reads = migrated_catalog + root_id = await _root(sec_db, str(tmp_path)) + response = await authenticated_client.post( + "/story-arcs/catalog/42", + data={ + "fingerprint": "a" * 64, + "library_root_id": root_id, + "issue_provider_ids": ["101", "102"], + "reading_orders": ["1", "2"], + }, + headers=_csrf(authenticated_client), + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/story-arcs/catalog/42?error=review" + reads.assert_not_awaited() + async with sec_db() as session: + assert await session.scalar(select(func.count()).select_from(StoryArc)) == 0 + + +async def test_legacy_search_respects_disabled_policy_instead_of_calling_comicvine( + authenticated_client, sec_db, migrated_catalog +): + provider, _ = migrated_catalog + async with sec_db.begin() as session: + await session.execute(update(MetadataSourceConfig).values(enabled=False, revision=2)) + response = await authenticated_client.get("/story-arcs/catalog?q=event") + assert response.status_code == 200 + assert "Story Arc search failed" in response.text + assert "No Story Arc matches" not in response.text + assert provider.searches == [] diff --git a/tests/ui/test_story_arc_catalog_ui.py b/tests/ui/test_story_arc_catalog_ui.py index 07fc18cc..e3a22cac 100644 --- a/tests/ui/test_story_arc_catalog_ui.py +++ b/tests/ui/test_story_arc_catalog_ui.py @@ -16,6 +16,7 @@ from pullbox.models.config import SystemConfig from pullbox.models.library import LibraryRoot +from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.models.story_arc import IssueStoryArc, StoryArc from pullbox.providers.metadata.comicvine import ComicVineError from pullbox.services.auth_service import SESSION_COOKIE_NAME, AuthService @@ -32,7 +33,12 @@ @pytest.fixture -def catalog_provider(monkeypatch: pytest.MonkeyPatch) -> CatalogProvider: +async def catalog_provider(monkeypatch: pytest.MonkeyPatch, sec_db) -> CatalogProvider: + # Match the source-settings migration, not an unmigrated create_all-only DB. + async with sec_db.begin() as session: + session.add( + MetadataSourceConfig(source="comicvine_api", enabled=True, priority=20, revision=1) + ) provider = CatalogProvider() monkeypatch.setattr( "pullbox.core.comicvine_key.get_comicvine_api_key", AsyncMock(return_value="test") @@ -62,6 +68,14 @@ def _csrf(client: AsyncClient) -> dict[str, str]: return {"X-CSRF-Token": AuthService.get_csrf_token_from_session(token) or ""} +def _review_fields(response) -> dict[str, str]: + return { + name: match[1] + for name in ("fingerprint", "source_revision", "file_defaults_fingerprint") + if (match := re.search(rf'name="{name}" value="([^"]+)"', response.text)) + } + + async def test_registry_links_to_dedicated_comicvine_add_page( authenticated_client: AsyncClient, ): @@ -145,14 +159,14 @@ async def test_catalog_search_marks_already_added_and_does_not_create( "/story-arcs/catalog?q=event", headers={"HX-Request": "true"} ) assert response.status_code == 200 - assert 'href="/story-arcs/catalog/42"' in response.text + assert 'href="/story-arcs/catalog/comicvine_api/42"' in response.text assert f'href="/story-arcs/{arc_id}"' in response.text assert 'class="pill pill-success"' in response.text assert "In Library" in response.text assert 'data-testid="story-arc-existing-cover-link"' in response.text assert 'data-testid="story-arc-existing-title-link"' in response.text assert "Already added" not in response.text - assert catalog_provider.searches == [("event", 20, 0)] + assert catalog_provider.searches == [("event", 100, 0)] assert catalog_provider.closed == 1 async with sec_db() as session: assert len(list(await session.scalars(select(StoryArc)))) == 1 @@ -179,7 +193,7 @@ async def test_catalog_preview_uses_review_workspace_and_separate_canonical_root ): response = await authenticated_client.get("/story-arcs/catalog/42") assert response.status_code == 200 - assert "Issues are listed in Comic Vine's returned order." in response.text + assert "Issues are listed in ComicVine API's returned order." in response.text assert "Every issue needs a different positive position." not in response.text assert "reading order unverified" not in response.text assert "1000000" in response.text and "1AU" in response.text @@ -285,7 +299,7 @@ async def test_provider_failure_is_safe_and_retryable( "/story-arcs/catalog?q=event", headers={"HX-Request": "true"} ) assert response.status_code == 200 - assert "Comic Vine search failed" in response.text + assert "Story Arc search failed" in response.text assert "secret-bearing" not in response.text assert 'role="alert"' in response.text @@ -304,7 +318,7 @@ async def test_add_rejects_empty_stale_or_invalid_member_set( token = re.search(r'name="fingerprint" value="([^"]+)"', response.text) assert token is not None data = { - "fingerprint": token.group(1), + **_review_fields(response), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], "reading_orders": ["2", "1"], @@ -356,7 +370,7 @@ async def test_add_persists_reviewed_order_and_independent_copy_settings( response = await authenticated_client.post( "/story-arcs/catalog/42", data={ - "fingerprint": token.group(1), + **_review_fields(response), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], "reading_orders": ["2", "1"], @@ -400,7 +414,7 @@ async def test_changed_file_defaults_require_repreview_before_adding( added = await authenticated_client.post( "/story-arcs/catalog/42", data={ - "fingerprint": token.group(1), + **_review_fields(preview), "file_defaults_fingerprint": defaults.group(1), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], @@ -410,7 +424,7 @@ async def test_changed_file_defaults_require_repreview_before_adding( follow_redirects=False, ) assert added.status_code == 303 - assert added.headers["location"].endswith("?error=file-defaults") + assert added.headers["location"].endswith("?error=file_defaults_changed") async with sec_db() as session: assert list(await session.scalars(select(StoryArc))) == [] @@ -432,7 +446,7 @@ async def test_failed_provider_cleanup_cannot_report_failure_after_committing_ad response = await authenticated_client.post( "/story-arcs/catalog/42", data={ - "fingerprint": token.group(1), + **_review_fields(response), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], "reading_orders": ["1", "2"], @@ -481,7 +495,7 @@ async def test_add_auto_search_requires_monitoring_and_global_default_and_runs_a response = await authenticated_client.post( "/story-arcs/catalog/42", data={ - "fingerprint": token.group(1), + **_review_fields(response), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], "reading_orders": ["1", "2"], @@ -522,7 +536,7 @@ async def test_refresh_reviews_additions_and_preserves_removed_members_and_order assert response.status_code == 200 assert 'data-testid="story-arc-update-additions"' in response.text assert "Exact Comics #2" in response.text - assert "Comic Vine issue ID 102 — preserved" in response.text + assert "ComicVine API issue ID 102 — preserved" in response.text token = re.search(r'name="fingerprint" value="([^"]+)"', response.text) revision = re.search(r'name="expected_revision" value="([^"]+)"', response.text) assert token is not None and revision is not None @@ -531,7 +545,7 @@ async def test_refresh_reviews_additions_and_preserves_removed_members_and_order response = await authenticated_client.post( f"/story-arcs/{arc_id}/catalog-refresh", data={ - "fingerprint": token.group(1), + **_review_fields(response), "expected_revision": revision.group(1), "confirm_refresh": "true", }, @@ -742,7 +756,7 @@ async def assert_committed(arc_id, **kwargs): response = await authenticated_client.post( "/story-arcs/catalog/42", data={ - "fingerprint": token.group(1), + **_review_fields(response), "library_root_id": str(root_id), "issue_provider_ids": ["101", "102"], "reading_orders": ["1", "2"], @@ -811,7 +825,7 @@ async def test_imported_arc_refresh_requires_explicit_root_for_new_series_only( revision = re.search(r'name="expected_revision" value="([^"]+)"', response.text) assert token is not None and revision is not None form = { - "fingerprint": token.group(1), + **_review_fields(response), "expected_revision": revision.group(1), "confirm_refresh": "true", } @@ -863,7 +877,7 @@ async def test_saved_unavailable_refresh_root_has_actionable_guidance( result = await authenticated_client.post( f"/story-arcs/{arc_id}/catalog-refresh", data={ - "fingerprint": token.group(1), + **_review_fields(preview), "expected_revision": revision.group(1), "confirm_refresh": "true", }, diff --git a/tests/unit/test_metadata_story_arc_reads.py b/tests/unit/test_metadata_story_arc_reads.py index f6036b26..d77ae1d4 100644 --- a/tests/unit/test_metadata_story_arc_reads.py +++ b/tests/unit/test_metadata_story_arc_reads.py @@ -87,6 +87,30 @@ async def test_exact_arc_and_cross_series_members_preserve_source_and_order(): assert adapter.closed == 2 +async def test_read_cleanup_failure_cannot_supply_metadata_for_a_catalog_write(monkeypatch): + adapter = ArcAdapter() + + async def failed_close(): + adapter.closed += 1 + raise RuntimeError("secret-bearing cleanup failure") + + monkeypatch.setattr(adapter, "close", failed_close) + result = await registry(adapter).story_arc(adapter.source, "42") + assert result.status is SourceStatus.UNAVAILABLE and result.data is None + assert adapter.closed == 1 + adapter.error = MetadataSourceError(SourceStatus.RATE_LIMITED, 60) + limited = await registry(adapter).story_arc(adapter.source, "42") + assert limited.status is SourceStatus.RATE_LIMITED and limited.retry_after_seconds == 60 + + adapter.error, adapter.wait = None, asyncio.Event() + adapter.started.clear() + running = asyncio.create_task(registry(adapter).story_arc(adapter.source, "42")) + await adapter.started.wait() + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + + @pytest.mark.parametrize( "updates", [ From 57b15f08f65e267e7b3c46ef7f5fd511dd6744d6 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 19:41:05 -0700 Subject: [PATCH 57/97] feat(metadata): preserve canonical creator credits --- docs/development/ARCHITECTURE_OVERVIEW.md | 15 +- docs/development/DATABASE_STANDARDS.md | 12 + .../metadata/comicvine_normalization.py | 6 + .../metadata/metron_normalization.py | 25 ++ src/pullbox/schemas/metadata_credits.py | 55 +++++ src/pullbox/schemas/metadata_snapshot.py | 2 + src/pullbox/schemas/metadata_sources.py | 2 + .../services/metadata_arc_baselines.py | 5 + src/pullbox/services/metadata_assembly.py | 12 +- src/pullbox/services/metadata_credits.py | 109 +++++++++ .../services/metadata_issue_catalog.py | 4 + .../services/metadata_refresh_snapshot.py | 3 +- .../services/metadata_series_adoption.py | 10 + .../services/metadata_series_refresh_state.py | 6 + src/pullbox/services/metadata_service.py | 11 +- .../test_arc_metadata_baselines.py | 37 +++ .../metadata_identity/test_issue_credits.py | 213 ++++++++++++++++++ .../metadata_identity/test_series_refresh.py | 10 +- tests/unit/test_metadata_credits.py | 210 +++++++++++++++++ 19 files changed, 735 insertions(+), 12 deletions(-) create mode 100644 src/pullbox/schemas/metadata_credits.py create mode 100644 src/pullbox/services/metadata_credits.py create mode 100644 tests/integration/metadata_identity/test_issue_credits.py create mode 100644 tests/unit/test_metadata_credits.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 1007b19e..34b2c305 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -436,8 +436,19 @@ commands. Pre-upgrade forms without a source revision must be previewed again; existing ComicVine arcs retain their identity and reviewed membership. New Add adopts the selected source, while refresh uses the descriptive cascade. Provider cleanup failure cannot supply a successful read for a write; primary cancellation -and rate-limit outcomes remain intact. Remaining legacy consumer migration, -complete credits/membership fields and coordinated XML/sidecar writers are pending. +and rate-limit outcomes remain intact. + +Normalized issue reads and canonical snapshots retain bounded descriptive creator +credits. Credit lists follow core-domain authority as a whole; lower-priority +lists do not merge over an existing list. User edits and intentional clears remain +overrides. Source-aware Add, issue catalog writes and newly seeded Story Arc +members persist credits through the existing Creator/IssueCreator relations in +the same transaction as their baseline. Refresh captures current relations in its +read set before provider I/O and rejects in-flight changes. Reads/writes batch at +200 issues; descriptive names never establish a foreign creator identity. No +additional provider detail calls or archive writes are introduced. Remaining +rich metadata/membership fields, legacy consumers and coordinated XML/sidecar +writers are pending. This does not complete the full metadata feature. The existing import/cache consumers have not yet migrated to this registry. diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 05afa164..ac471999 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -464,6 +464,18 @@ A stale read aborts the complete membership and metadata write, including when a caller catches the domain error. Secondary sources never acquire membership or identity ownership through descriptive enrichment. +Issue snapshots can retain up to 128 descriptive creator credits. Each credit +has a bounded name and normalized role list compatible with the existing +Creator/IssueCreator columns. Relations and baseline values commit together; +provider absence does not clear local credits. Refresh includes current creator +names/roles in its read set to detect edits, clears and concurrent changes. +Credit reads and writes use 200-issue batches, not per-issue queries. Writers +retain existing linked creator IDs when possible and never rename a shared +creator or claim another provider's creator ID from a name match. The legacy +ComicVine writer likewise resolves known IDs exactly; name-only reuse is limited +to unclaimed descriptive rows. No credit schema migration or provenance backfill +is needed for this additive snapshot field. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/providers/metadata/comicvine_normalization.py b/src/pullbox/providers/metadata/comicvine_normalization.py index b04cef88..f7ad1fc3 100644 --- a/src/pullbox/providers/metadata/comicvine_normalization.py +++ b/src/pullbox/providers/metadata/comicvine_normalization.py @@ -5,6 +5,7 @@ from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource from pullbox.providers.base import IssueMetadata, SeriesMetadata +from pullbox.schemas.metadata_credits import parse_credits from pullbox.schemas.metadata_sources import MetadataPage, ProviderIssueRead, ProviderSeriesRead from pullbox.services.catalog.reader import CatalogIssueMetadata, CatalogSeriesMetadata from pullbox.services.metadata_source_reads import source_id @@ -62,6 +63,11 @@ def issue(source: MetadataSource, row: IssueMetadata) -> ProviderIssueRead: except ValueError: key = None return ProviderIssueRead( + credits=parse_credits( + [{"name": credit.get("name"), "role": credit.get("role")} for credit in row.creators] + ) + if row.creators + else None, source=source, identity_namespace=source.identity_namespace, external_id=identity, diff --git a/src/pullbox/providers/metadata/metron_normalization.py b/src/pullbox/providers/metadata/metron_normalization.py index cfc32bd0..d8e180f0 100644 --- a/src/pullbox/providers/metadata/metron_normalization.py +++ b/src/pullbox/providers/metadata/metron_normalization.py @@ -14,6 +14,7 @@ MetadataEntityKind, MetadataSource, ) +from pullbox.schemas.metadata_credits import MAX_CREDITS, MetadataCredit, parse_credits from pullbox.schemas.metadata_sources import ( ProviderIssueRead, ProviderSeriesRead, @@ -181,6 +182,29 @@ def series(value: object, *, detail: bool = False) -> ProviderSeriesRead: ) +def _credits(value: object) -> tuple[MetadataCredit, ...] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) > MAX_CREDITS: + raise ValueError("Expected bounded creator credits") + credits = [] + for item in value: + row = object_row(item) + roles = row.get("role") + if not isinstance(roles, list) or len(roles) > 32: + raise ValueError("Expected bounded creator roles") + credits.append( + { + "name": _text(row.get("creator"), required=True, limit=255), + "role": ", ".join( + _text(object_row(role).get("name"), required=True, limit=100) or "" + for role in roles + ), + } + ) + return parse_credits(credits) + + def issue(value: object) -> ProviderIssueRead: row = object_row(value) number = _text(row.get("number"), required=True, limit=100) @@ -190,6 +214,7 @@ def issue(value: object) -> ProviderIssueRead: except ValueError: key = None return ProviderIssueRead( + credits=_credits(row.get("credits")), source=MetadataSource.METRON_API, identity_namespace=IdentityNamespace.METRON, external_id=external_id(row.get("id")), diff --git a/src/pullbox/schemas/metadata_credits.py b/src/pullbox/schemas/metadata_credits.py new file mode 100644 index 00000000..c3de8a09 --- /dev/null +++ b/src/pullbox/schemas/metadata_credits.py @@ -0,0 +1,55 @@ +"""Bounded descriptive credits shared by providers, snapshots and library relations.""" + +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, TypeAdapter, field_validator + +MAX_CREDITS = 128 + + +class MetadataCredit(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str = Field(min_length=1, max_length=255) + role: str = Field(max_length=100) + + @field_validator("name", mode="after") + @classmethod + def normalize(cls, value: str) -> str: + result = value.strip() + if not result or any(ord(char) < 32 for char in result): + raise ValueError("Credits require nonempty printable text") + return result + + @field_validator("role", mode="after") + @classmethod + def normalize_roles(cls, value: str) -> str: + if any(ord(char) < 32 for char in value): + raise ValueError("Creator roles require printable text") + if not value.strip(): + return "" + roles = [role.strip().casefold() for role in value.split(",")] + if any(not role for role in roles): + raise ValueError("Credits require nonempty roles") + return ", ".join(sorted(set(roles))) + + +def _normalize_credits(values: tuple[MetadataCredit, ...]) -> tuple[MetadataCredit, ...]: + roles: dict[str, set[str]] = {} + for credit in values: + roles.setdefault(credit.name, set()).update(credit.role.split(", ") if credit.role else ()) + # Descriptive grouping only: names never prove a foreign creator identity. + return tuple( + MetadataCredit(name=name, role=", ".join(sorted(roles[name]))) for name in sorted(roles) + ) + + +type MetadataCredits = Annotated[ + tuple[MetadataCredit, ...], Field(max_length=MAX_CREDITS), AfterValidator(_normalize_credits) +] + +_CREDITS: TypeAdapter[tuple[MetadataCredit, ...]] = TypeAdapter(MetadataCredits) + + +def parse_credits(value: object) -> tuple[MetadataCredit, ...]: + return _CREDITS.validate_python(value) diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py index d20dcd2a..425a7782 100644 --- a/src/pullbox/schemas/metadata_snapshot.py +++ b/src/pullbox/schemas/metadata_snapshot.py @@ -6,6 +6,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.schemas.metadata_credits import MetadataCredits from pullbox.schemas.metadata_sources import MetadataDomain @@ -38,6 +39,7 @@ class MetadataValues(BaseModel): store_date: date | None = None page_count: int | None = Field(default=None, ge=0, le=1000000, strict=True) image_url: str | None = Field(default=None, max_length=500) + credits: MetadataCredits | None = None class FieldOrigin(BaseModel): diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index a044bd32..c2ad2a61 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -12,6 +12,7 @@ MetadataEntityKind, MetadataSource, ) +from pullbox.schemas.metadata_credits import MetadataCredits class MetadataDomain(enum.StrEnum): @@ -157,6 +158,7 @@ class ProviderSeriesRead(BaseModel): class ProviderIssueRead(BaseModel): + credits: MetadataCredits | None = None source: MetadataSource identity_namespace: IdentityNamespace external_id: str diff --git a/src/pullbox/services/metadata_arc_baselines.py b/src/pullbox/services/metadata_arc_baselines.py index 4472d6ec..71186503 100644 --- a/src/pullbox/services/metadata_arc_baselines.py +++ b/src/pullbox/services/metadata_arc_baselines.py @@ -26,6 +26,7 @@ load_metadata_baseline, save_metadata_baselines, ) +from pullbox.services.metadata_credits import write_issue_credits from pullbox.services.metadata_series_adoption import persist_adoption_series_baseline from pullbox.services.metadata_sources import read_source_policies from pullbox.services.story_arc_catalog_types import StoryArcCatalogPreview @@ -221,4 +222,8 @@ async def persist_seeded_metadata( issue.cover_url = snapshot.values.image_url writes.append(MetadataBaselineWrite(issue.id, snapshot)) for offset in range(0, len(writes), 200): + await write_issue_credits( + session, + {item.local_id: item.snapshot.values.credits for item in writes[offset : offset + 200]}, + ) await save_metadata_baselines(session, writes[offset : offset + 200]) diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py index 651c172d..e4219c57 100644 --- a/src/pullbox/services/metadata_assembly.py +++ b/src/pullbox/services/metadata_assembly.py @@ -27,8 +27,8 @@ class MetadataAssemblyError(ValueError): """Evidence cannot safely describe one canonical entity.""" -def _missing(value: object) -> bool: - return value is None or (isinstance(value, str) and not value.strip()) +def missing_metadata_value(value: object) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) or value == () def _identity_map( @@ -142,7 +142,7 @@ def assemble_metadata( if current.image_url and not allowed_artwork_url(current.image_url): raise MetadataAssemblyError("Existing artwork requires a supported public provider URL.") origins = {item.field: item for item in previous.origins} if previous else {} - values = current.model_dump() + values = {field: getattr(current, field) for field in MetadataValues.model_fields} assembled: list[FieldOrigin] = [] def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: @@ -165,7 +165,7 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: for source in sorted(normalized, key=lambda source: rank(source, domain)): candidate, incoming = normalized[source] value = getattr(incoming, field) - if _missing(value): + if missing_metadata_value(value): continue may_replace = ( replace_managed @@ -178,7 +178,7 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: ) ) ) - if _missing(existing) or may_replace: + if missing_metadata_value(existing) or may_replace: values[field] = value chosen = FieldOrigin( field=field, @@ -188,7 +188,7 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: observed_at=now, ) break - if not _missing(values[field]) or protected or prior is not None: + if not missing_metadata_value(values[field]) or protected or prior is not None: assembled.append(chosen) return MetadataSnapshot( entity_kind=kind, diff --git a/src/pullbox/services/metadata_credits.py b/src/pullbox/services/metadata_credits.py new file mode 100644 index 00000000..5c114933 --- /dev/null +++ b/src/pullbox/services/metadata_credits.py @@ -0,0 +1,109 @@ +"""Batch descriptive credits inside the caller's locked metadata transaction.""" + +from collections.abc import Mapping, Sequence + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models.creator import Creator, IssueCreator +from pullbox.schemas.metadata_credits import MAX_CREDITS, MetadataCredit, parse_credits + +_BATCH = 200 + + +async def _rows(session: AsyncSession, issue_ids: Sequence[int]) -> list[tuple[int, int, str, str]]: + rows = list( + ( + await session.execute( + select(IssueCreator.issue_id, Creator.id, Creator.name, IssueCreator.role) + .join(Creator, Creator.id == IssueCreator.creator_id) + .where(IssueCreator.issue_id.in_(issue_ids)) + .order_by(IssueCreator.issue_id, Creator.id) + .limit(len(issue_ids) * MAX_CREDITS + 1) + ) + ) + .tuples() + .all() + ) + if len(rows) > len(issue_ids) * MAX_CREDITS: + raise ValueError("Stored creator credits exceed the bounded metadata limit.") + return rows + + +def _group(rows: Sequence[tuple[int, int, str, str]]) -> dict[int, tuple[MetadataCredit, ...]]: + grouped: dict[int, list[MetadataCredit]] = {} + for issue_id, _, name, role in rows: + grouped.setdefault(issue_id, []).append(MetadataCredit(name=name, role=role)) + return {issue_id: parse_credits(values) for issue_id, values in grouped.items()} + + +async def read_issue_credits( + session: AsyncSession, issue_ids: Sequence[int] +) -> dict[int, tuple[MetadataCredit, ...]]: + result = {} + for offset in range(0, len(issue_ids), _BATCH): + result.update(_group(await _rows(session, issue_ids[offset : offset + _BATCH]))) + return result + + +async def write_issue_credits( + session: AsyncSession, values: Mapping[int, tuple[MetadataCredit, ...] | None] +) -> None: + """Persist assembled values, never assign foreign creator IDs by name. + + Existing issue links retain their creator IDs where possible. Generic + descriptive names can be reused, but a name alone cannot adopt another + issue's ComicVine-identified creator or rename a shared creator row. + None means no evidence; an empty tuple is an intentional local clear. + """ + if len(values) > _BATCH: + raise ValueError("Write creator credits in batches of at most 200 issues.") + incoming = {key: value for key, value in values.items() if value is not None} + if not incoming: + return + rows = await _rows(session, list(incoming)) + current = _group(rows) + changed = {key: value for key, value in incoming.items() if current.get(key, ()) != value} + if not changed: + return + existing = {(issue_id, name): creator_id for issue_id, creator_id, name, _ in reversed(rows)} + names = sorted({credit.name for credits in changed.values() for credit in credits}) + generic: dict[str, int] = {} + for offset in range(0, len(names), _BATCH): + generic.update( + ( + await session.execute( + select(Creator.name, func.min(Creator.id)) + .where( + Creator.name.in_(names[offset : offset + _BATCH]), + Creator.comicvine_id.is_(None), + ) + .group_by(Creator.name) + ) + ) + .tuples() + .all() + ) + needed = { + credit.name + for issue_id, credits in changed.items() + for credit in credits + if credit.name not in generic and (issue_id, credit.name) not in existing + } + pending = {name: Creator(name=name) for name in sorted(needed)} + session.add_all(pending.values()) + await session.flush() + generic.update({name: creator.id for name, creator in pending.items()}) + await session.execute(delete(IssueCreator).where(IssueCreator.issue_id.in_(changed))) + session.add_all( + [ + IssueCreator( + issue_id=issue_id, + creator_id=existing.get((issue_id, credit.name)) or generic[credit.name], + role=credit.role, + ) + for issue_id, credits in changed.items() + for credit in credits + ] + ) + await session.flush() diff --git a/src/pullbox/services/metadata_issue_catalog.py b/src/pullbox/services/metadata_issue_catalog.py index b59dafa2..6d340d55 100644 --- a/src/pullbox/services/metadata_issue_catalog.py +++ b/src/pullbox/services/metadata_issue_catalog.py @@ -11,6 +11,7 @@ from pullbox.models.issue import IssueStatus from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_credits import write_issue_credits from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation from pullbox.services.metadata_series_adoption import ( @@ -152,6 +153,9 @@ async def apply_issue_batch( await record_identity_observation( session, _request(batch, metadata, crosswalk, issue.id, observation=True) ) + await write_issue_credits( + session, {issue.id: item.values.credits for issue, _, _, item in pending} + ) await save_metadata_baselines( session, [ diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py index f32434ef..5794cd19 100644 --- a/src/pullbox/services/metadata_refresh_snapshot.py +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -20,6 +20,7 @@ MetadataAssemblyError, MetadataCandidate, assemble_metadata, + missing_metadata_value, ) from pullbox.services.metadata_discovery import MetadataSourceRegistry @@ -93,7 +94,7 @@ def assemble(*, local: bool) -> MetadataSnapshot: needed = set() for field in requested_fields: value = getattr(snapshot.values, field) - missing = value is None or (isinstance(value, str) and not value.strip()) + missing = missing_metadata_value(value) origin = initial.get(field) if origin is not None and origin.user_override: continue diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index a94ac515..b51513af 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -48,6 +48,7 @@ CatalogCheckpointConflictError, save_full_catalog_checkpoint, ) +from pullbox.services.metadata_credits import write_issue_credits from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation @@ -558,6 +559,15 @@ async def _adopt( await record_identity_observation( session, _request(bundle, metadata, crosswalk, issue.id, observation=True) ) + await write_issue_credits( + session, + { + issue.id: item.values.credits + for (issue, _), item in zip( + members, issue_snapshots[offset : offset + 200], strict=True + ) + }, + ) await save_metadata_baselines( session, [ diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index 84a052ea..0b2fc233 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -25,6 +25,7 @@ CatalogCheckpoint, read_catalog_checkpoints, ) +from pullbox.services.metadata_credits import read_issue_credits from pullbox.services.metadata_sources import read_source_policies SERIES_FIELDS = frozenset( @@ -52,6 +53,7 @@ "store_date", "page_count", "image_url", + "credits", } ) @@ -199,6 +201,7 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se .tuples() .all() ) + credits = await read_issue_credits(session, [issue.id for issue, _, _ in members]) issues = [] for issue, issue_revision, issue_payload in members: previous = _baseline(issue_payload, MetadataEntityKind.ISSUE) @@ -211,6 +214,9 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se store_date=issue.store_date, page_count=issue.page_count, image_url=issue.cover_url, + credits=credits.get( + issue.id, () if previous and previous.values.credits is not None else None + ), ) issues.append( RefreshEntityState( diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py index f191c0ff..edd6e405 100644 --- a/src/pullbox/services/metadata_service.py +++ b/src/pullbox/services/metadata_service.py @@ -772,9 +772,16 @@ async def _get_or_create_creator( creator = ( await session.execute(select(Creator).where(Creator.comicvine_id == comicvine_id)) ).scalar_one_or_none() - if creator is None: + else: + # A name is descriptive evidence, not proof of another creator's + # ComicVine identity. Reuse only an unclaimed descriptive row. creator = ( - await session.execute(select(Creator).where(Creator.name == name)) + await session.execute( + select(Creator) + .where(Creator.name == name, Creator.comicvine_id.is_(None)) + .order_by(Creator.id) + .limit(1) + ) ).scalar_one_or_none() if creator is None: creator = Creator(name=name, comicvine_id=comicvine_id, comicvine_url=comicvine_url) diff --git a/tests/integration/metadata_identity/test_arc_metadata_baselines.py b/tests/integration/metadata_identity/test_arc_metadata_baselines.py index 9fb3001e..f3cdd491 100644 --- a/tests/integration/metadata_identity/test_arc_metadata_baselines.py +++ b/tests/integration/metadata_identity/test_arc_metadata_baselines.py @@ -41,6 +41,43 @@ async def _preview(count=2, **changes): return await fetch(adapter) +async def test_new_arc_member_credits_reach_library_relations(identity_probe_db, tmp_path): + from pullbox.schemas.metadata_credits import parse_credits + from pullbox.utilities.comicinfo_creators import load_comicinfo_creator_fields + + _, factory, _ = identity_probe_db + root = await _setup(factory, tmp_path) + preview = await _preview() + evidence = preview.source_evidence + preview = project_source_arc_catalog( + replace( + evidence, + issues=tuple( + row.model_copy( + update={ + "credits": parse_credits( + [ + {"name": "Arc Creator", "role": "writer"}, + ] + ) + } + ) + for row in evidence.issues + ), + ), + 1, + ) + async with factory.begin() as session: + await _add(_service(), session, preview, root) + async with factory() as session: + issue_ids = list(await session.scalars(select(Issue.id))) + assert len(issue_ids) == 2 + for issue_id in issue_ids: + assert await load_comicinfo_creator_fields(session, issue_id) == { + "Writer": "Arc Creator" + } + + @pytest.mark.parametrize("source", [MetadataSource.METRON_API, MetadataSource.COMICVINE_API]) async def test_arc_add_persists_graph_baselines_across_reconnect( identity_probe_db, tmp_path, source diff --git a/tests/integration/metadata_identity/test_issue_credits.py b/tests/integration/metadata_identity/test_issue_credits.py new file mode 100644 index 00000000..bb28f137 --- /dev/null +++ b/tests/integration/metadata_identity/test_issue_credits.py @@ -0,0 +1,213 @@ +"""Canonical credits use the existing reader relations without claiming creator IDs.""" + +from dataclasses import replace + +import pytest +from sqlalchemy import delete, event, func, select + +from pullbox.models import Issue +from pullbox.models.creator import Creator, IssueCreator +from pullbox.schemas.metadata_credits import parse_credits +from pullbox.services.metadata_baselines import load_metadata_baseline +from pullbox.services.metadata_credits import read_issue_credits, write_issue_credits +from pullbox.services.metadata_series_adoption import adopt_source_series_bundle +from pullbox.services.metadata_series_refresh import refresh_series_from_sources +from pullbox.services.metadata_service import MetadataService +from pullbox.utilities.comicinfo_creators import load_comicinfo_creator_fields +from tests.integration.metadata_identity.test_series_adoption import ( # noqa: F401 + bundle, + configured_sources, +) +from tests.integration.metadata_identity.test_series_refresh import RefreshAdapter, refresh_registry +from tests.unit.test_metadata_assembly import Kind +from tests.unit.test_metadata_credits import candidate, metron_credits + + +def credited_bundle(name="Fixture Author"): + return replace(bundle(numbers=("1",)), issues=(candidate(credits=metron_credits(name)),)) + + +async def test_add_persists_credits_in_baseline_and_existing_xml_reader(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, credited_bundle(), monitored=True) + issue_id = await session.scalar(select(Issue.id).where(Issue.series_id == result.series.id)) + async with factory() as session: + assert await load_comicinfo_creator_fields(session, issue_id) == { + "Writer": "Fixture Author" + } + saved = await load_metadata_baseline(session, Kind.ISSUE, issue_id) + assert saved.snapshot.values.model_dump(mode="json")["credits"] == [ + {"name": "Fixture Author", "role": "writer"}, + ] + assert (await session.scalar(select(Creator))).comicvine_id is None + + +@pytest.mark.parametrize("edit", ["none", "rename", "clear"]) +async def test_refresh_replaces_only_managed_credits_and_preserves_edits(identity_probe_db, edit): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + result = await adopt_source_series_bundle(session, credited_bundle(), monitored=True) + series_id = result.series.id + issue_id = await session.scalar(select(Issue.id).where(Issue.series_id == series_id)) + assert await load_comicinfo_creator_fields(session, issue_id) == { + "Writer": "Fixture Author" + } + async with factory.begin() as session: + if edit == "rename": + (await session.scalar(select(Creator))).name = "Local Author" + elif edit == "clear": + await session.execute(delete(IssueCreator).where(IssueCreator.issue_id == issue_id)) + async with factory() as session: + await refresh_series_from_sources( + session, + series_id, + registry=refresh_registry(RefreshAdapter(credited_bundle("Replacement"))), + ) + await session.commit() + async with factory() as session: + assert await load_comicinfo_creator_fields(session, issue_id) == ( + {} + if edit == "clear" + else {"Writer": "Local Author" if edit == "rename" else "Replacement"} + ) + saved = await load_metadata_baseline(session, Kind.ISSUE, issue_id) + assert next(o for o in saved.snapshot.origins if o.field == "credits").user_override == ( + edit != "none" + ) + + +async def test_caller_rollback_removes_new_credit_graph(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory() as session: + await adopt_source_series_bundle(session, credited_bundle(), monitored=True) + assert await session.scalar(select(func.count()).select_from(IssueCreator)) == 1 + await session.rollback() + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(IssueCreator)) == 0 + assert await session.scalar(select(func.count()).select_from(Creator)) == 0 + assert await session.scalar(select(func.count()).select_from(Issue)) == 0 + + +async def test_descriptive_names_never_assign_or_rewrite_foreign_creator_identity( + identity_probe_db, +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + known = Creator( + name="Fixture Author", + comicvine_id=77, + comicvine_url="https://comicvine.gamespot.com/person/4040-77/", + ) + session.add(known) + await session.flush() + known_id = known.id + result = await adopt_source_series_bundle(session, credited_bundle(), monitored=True) + issue_id = await session.scalar(select(Issue.id).where(Issue.series_id == result.series.id)) + link = await session.scalar(select(IssueCreator).where(IssueCreator.issue_id == issue_id)) + assert link.creator_id != known_id + assert (await session.get(Creator, link.creator_id)).comicvine_id is None + # An already-linked legacy creator is retained, not replaced or renamed globally. + await session.execute(delete(IssueCreator).where(IssueCreator.issue_id == issue_id)) + session.add(IssueCreator(issue_id=issue_id, creator_id=known_id, role="writer")) + async with factory.begin() as session: + await write_issue_credits( + session, + { + issue_id: parse_credits( + [ + {"name": "Fixture Author", "role": "writer, inker"}, + ] + ) + }, + ) + async with factory() as session: + link = await session.scalar(select(IssueCreator).where(IssueCreator.issue_id == issue_id)) + assert link.creator_id == known_id and link.role == "inker, writer" + known = await session.get(Creator, known_id) + assert known.name == "Fixture Author" and known.comicvine_id == 77 + + +async def test_credit_read_and_write_queries_are_batched_and_reuse_descriptive_rows( + identity_probe_db, +): + engine, factory, _ = identity_probe_db + data = bundle(numbers=tuple(str(i) for i in range(401))) + async with factory.begin() as session: + await adopt_source_series_bundle(session, data, monitored=True) + ids = list(await session.scalars(select(Issue.id).order_by(Issue.id))) + statements = [] + + def capture(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", capture) + credits = parse_credits([{"name": "Shared", "role": "writer"}]) + try: + async with factory.begin() as session: + for offset in range(0, len(ids), 200): + await write_issue_credits(session, {i: credits for i in ids[offset : offset + 200]}) + assert len(statements) <= 13, "Credit queries must not scale one per issue" + statements.clear() + async with factory() as session: + assert await read_issue_credits(session, ids) == dict.fromkeys(ids, credits) + assert len(statements) == 3 + finally: + event.remove(engine.sync_engine, "before_cursor_execute", capture) + async with factory() as session: + assert await session.scalar(select(func.count()).select_from(Creator)) == 1 + + +async def test_credit_write_limits_fail_before_any_mutation(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + with pytest.raises(ValueError, match="200"): + await write_issue_credits(session, dict.fromkeys(range(201), ())) + assert await session.scalar(select(func.count()).select_from(Creator)) == 0 + + +@pytest.mark.parametrize("with_generic", [False, True]) +async def test_legacy_writer_never_claims_same_name_as_foreign_identity( + identity_probe_db, with_generic +): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + original = Creator(name="Shared name", comicvine_id=77) + session.add(original) + if with_generic: + session.add(Creator(name="Shared name")) + await session.flush() + original_id = original.id + try: + new = await MetadataService._get_or_create_creator( + session, + name="Shared name", + comicvine_id=88, + comicvine_url=None, + ) + except Exception as exc: + pytest.fail(f"A name collision must not break the legacy writer: {type(exc).__name__}") + assert new.id != original_id, "Equal names must not reassign an existing provider identity" + assert original.comicvine_id == 77 and new.comicvine_id == 88 + same = await MetadataService._get_or_create_creator( + session, + name="Updated name", + comicvine_id=88, + comicvine_url=None, + ) + assert same.id == new.id + + +async def test_legacy_nameless_identity_write_uses_only_generic_creator(identity_probe_db): + _, factory, _ = identity_probe_db + async with factory.begin() as session: + original = Creator(name="Shared", comicvine_id=77) + session.add(original) + await session.flush() + generic = await MetadataService._get_or_create_creator( + session, + name="Shared", + comicvine_id=None, + comicvine_url=None, + ) + assert generic.id != original.id and generic.comicvine_id is None diff --git a/tests/integration/metadata_identity/test_series_refresh.py b/tests/integration/metadata_identity/test_series_refresh.py index b2e510dc..16e8b8cc 100644 --- a/tests/integration/metadata_identity/test_series_refresh.py +++ b/tests/integration/metadata_identity/test_series_refresh.py @@ -13,6 +13,7 @@ from pullbox.core.metadata_identity import MetadataEntityKind as Kind from pullbox.core.metadata_identity import MetadataSource as Source from pullbox.models import Issue, Series +from pullbox.models.creator import Creator, IssueCreator from pullbox.models.issue import IssueStatus from pullbox.models.metadata_baseline import SeriesMetadataBaseline from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity @@ -144,7 +145,9 @@ async def test_refresh_preserves_local_edits_and_explicit_status_override(identi ).user_override -@pytest.mark.parametrize("change", ["series_edit", "issue_edit", "identity", "policy", "baseline"]) +@pytest.mark.parametrize( + "change", ["series_edit", "issue_edit", "identity", "policy", "baseline", "credits"] +) async def test_inflight_change_rejects_whole_refresh_without_losing_newer_state( identity_probe_db, change ): @@ -176,6 +179,11 @@ async def test_inflight_change_rejects_whole_refresh_without_losing_newer_state( await editor.execute( update(Issue).where(Issue.id == issue_id).values(title="Newer issue edit") ) + elif change == "credits": + author = Creator(name="User added author") + editor.add(author) + await editor.flush() + editor.add(IssueCreator(issue_id=issue_id, creator_id=author.id, role="writer")) elif change == "identity": await editor.execute( update(SeriesExternalIdentity).values( diff --git a/tests/unit/test_metadata_credits.py b/tests/unit/test_metadata_credits.py new file mode 100644 index 00000000..ee214921 --- /dev/null +++ b/tests/unit/test_metadata_credits.py @@ -0,0 +1,210 @@ +"""Creator credits survive source normalization and canonical authority.""" + +import httpx +import pytest + +from pullbox.providers.base import IssueMetadata +from pullbox.providers.metadata import comicvine_normalization, metron_normalization +from pullbox.schemas.metadata_credits import parse_credits +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.services.metadata_assembly import assemble_metadata +from tests.unit.test_comicvine_source_reads import issue_payload +from tests.unit.test_metadata_assembly import CV, METRON, NOW, Kind, identity, origin +from tests.unit.test_metadata_discovery import runtime +from tests.unit.test_metadata_source_adapters import api_adapter +from tests.unit.test_metron_source import issue_row, source + +EXPECTED = [{"name": "Fixture Author", "role": "inker, writer"}] + + +def candidate(source_kind=METRON, credits=None): + if source_kind is METRON: + row = issue_row() + row.update(id=100, series={"id": 42}, number="1") + if credits is not None: + row["credits"] = credits + return metron_normalization.issue(row) + return comicvine_normalization.issue( + CV, + IssueMetadata( + provider_id="100", + series_provider_id="42", + issue_number=1, + issue_number_text="1", + creators=credits or [], + title=None, + description=None, + release_date=None, + store_date=None, + cover_url=None, + page_count=None, + comicvine_url=None, + ), + ) + + +def metron_credits(name="Fixture Author", role="Writer"): + return [{"id": 77, "creator": name, "role": [{"id": 1, "name": role}]}] + + +def assemble(candidates, **kwargs): + return assemble_metadata( + Kind.ISSUE, + [identity(CV, Kind.ISSUE, "100"), identity(METRON, Kind.ISSUE, "100")], + candidates, + [runtime(CV).policy, runtime(METRON).policy], + now=NOW, + parent_identities=[identity(CV), identity(METRON)], + **kwargs, + ) + + +@pytest.mark.parametrize("provider", ["comicvine", "metron"]) +async def test_http_detail_retains_all_roles_without_extra_requests(provider): + calls = [] + + def handle(request): + calls.append(request) + if provider == "comicvine": + return httpx.Response( + 200, + json={ + "status_code": 1, + "results": issue_payload( + person_credits=[ + {"id": 77, "name": "Fixture Author", "role": "writer, inker"} + ], + ), + }, + ) + return httpx.Response( + 200, + json={ + **issue_row(123), + "credits": [ + { + "id": 77, + "creator": "Fixture Author", + "role": [ + {"id": 1, "name": "Writer"}, + {"id": 2, "name": "Inker"}, + ], + }, + ], + }, + ) + + adapter = await api_adapter(handle) if provider == "comicvine" else source(handle) + try: + result = await adapter.issue("123") + assert result.data.model_dump(mode="json").get("credits") == EXPECTED + assert len(calls) == 1 + finally: + await adapter.close() + + +def test_snapshot_credit_authority_roundtrip_and_explicit_refresh(): + primary = candidate(CV, [{"name": "Primary", "role": "writer"}]) + secondary = candidate(credits=metron_credits("Secondary")) + before = assemble([secondary, primary]) + assert before.values.model_dump(mode="json").get("credits") == [ + {"name": "Primary", "role": "writer"}, + ] + assert origin(before, "credits").source is CV + assert before == MetadataSnapshot.model_validate_json(before.model_dump_json()) + replacement = candidate(CV, [{"name": "Replacement", "role": "writer"}]) + assert assemble([replacement], previous=before).values == before.values + refreshed = assemble([replacement], previous=before, replace_managed=True) + assert refreshed.values.model_dump(mode="json")["credits"][0]["name"] == "Replacement" + assert not origin(refreshed, "credits").user_override + + +def test_empty_provider_credits_allow_fallback_without_merging_lists(): + result = assemble([candidate(CV), candidate(credits=metron_credits())]) + assert result.values.model_dump(mode="json").get("credits") == [ + {"name": "Fixture Author", "role": "writer"}, + ] + assert origin(result, "credits").source is METRON + + +@pytest.mark.parametrize( + "invalid", ["not a list", [None], metron_credits(role="x" * 101), metron_credits() * 129] +) +def test_invalid_metron_credits_are_not_silently_discarded(invalid): + with pytest.raises(ValueError): + candidate(credits=invalid) + + +def test_normalized_roles_must_still_fit_database_storage(): + with pytest.raises(ValueError): + parse_credits([{"name": "Artist", "role": ",".join(f"r{i}" for i in range(25))}]) + + +def test_duplicate_roles_and_unknown_roles_survive_normalization(): + result = parse_credits( + [ + {"name": "Artist", "role": "Writer, inker"}, + {"name": "Artist", "role": "WRITER, Design consultant"}, + ] + ) + assert len(result) == 1 + assert result[0].role == "design consultant, inker, writer" + + +def test_metron_credit_without_assigned_role_is_retained_not_invented(): + try: + result = candidate(credits=[{"id": 77, "creator": "Unclassified", "role": []}]) + except ValueError: + pytest.fail("A valid unclassified creator must not reject the whole issue") + assert result.model_dump(mode="json")["credits"] == [{"name": "Unclassified", "role": ""}] + + +@pytest.mark.parametrize("local", [[], [{"name": "Local", "role": "translator"}]]) +def test_local_credits_and_clear_remain_overrides_across_refreshes(local): + before = assemble([candidate(credits=metron_credits())]) + current = MetadataValues.model_validate({**before.values.model_dump(), "credits": local}) + result = assemble( + [candidate(credits=metron_credits("New"))], + current=current, + previous=before, + replace_managed=True, + ) + assert result.values.credits == current.credits + assert origin(result, "credits").user_override + next_result = assemble( + [candidate(credits=metron_credits("Other"))], previous=result, replace_managed=True + ) + assert next_result.values.credits == current.credits + + +async def test_empty_credits_trigger_exact_fallback_but_local_clear_does_not(): + from pullbox.schemas.metadata_sources import MetadataFetch, SourceStatus + from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot + from tests.unit.test_metadata_refresh_snapshot import setup + + registry, adapters = setup() + adapters[CV].result = MetadataFetch(status=SourceStatus.OK, data=candidate(CV)) + adapters[METRON].result = MetadataFetch( + status=SourceStatus.OK, data=candidate(credits=metron_credits()) + ) + options = dict( + now=NOW, + requested_fields=frozenset({"credits"}), + parent_identities=[identity(CV), identity(METRON)], + ) + ids = [identity(CV, Kind.ISSUE, "100"), identity(METRON, Kind.ISSUE, "100")] + result = await fetch_metadata_snapshot( + registry, Kind.ISSUE, ids, current=MetadataValues(credits=()), **options + ) + assert result.snapshot.values.credits[0].name == "Fixture Author" + assert all(len(a.calls) == 1 for a in adapters.values()) + await fetch_metadata_snapshot( + registry, + Kind.ISSUE, + ids, + current=result.snapshot.values.model_copy(update={"credits": ()}), + previous=result.snapshot, + replace_managed=True, + **options, + ) + assert all(len(a.calls) == 1 for a in adapters.values()) From 296021062d0b537605941664f03ff532a2ae3f22 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 20:17:41 -0700 Subject: [PATCH 58/97] feat(metadata): retain rich MetronInfo archive values --- docs/development/ARCHITECTURE_OVERVIEW.md | 10 + src/pullbox/core/metroninfo.py | 158 ++++++++++++-- tests/fixtures/metroninfo/README.md | 9 + tests/fixtures/metroninfo/descriptive.xml | 30 +++ tests/unit/test_archive_metadata.py | 62 +++++- tests/unit/test_metroninfo_descriptive.py | 245 ++++++++++++++++++++++ 6 files changed, 498 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/metroninfo/descriptive.xml create mode 100644 tests/unit/test_metroninfo_descriptive.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 34b2c305..cc9ccd5d 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -451,6 +451,16 @@ rich metadata/membership fields, legacy consumers and coordinated XML/sidecar writers are pending. This does not complete the full metadata feature. +The bounded MetronInfo reader preserves descriptive fields, creator/role resources, +localized aliases and schema-specific resource IDs alongside exact issue/series +evidence. Resource IDs remain opaque document data, not foreign identity claims. +Missing credits differ from an explicit empty list; malformed or over-limit +credits cannot produce a partial list. Unsupported elements and attributes still +raise preservation diagnostics. The paired archive probe reads both XML documents +in one open pass without changing reference files. This is read support, not +offline XSD validation or permission to rewrite either document; coordinated +canonical output remains pending. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/core/metroninfo.py b/src/pullbox/core/metroninfo.py index a21fdbc6..755d0773 100644 --- a/src/pullbox/core/metroninfo.py +++ b/src/pullbox/core/metroninfo.py @@ -25,6 +25,8 @@ MAX_METRONINFO_BYTES = 2 * 1024 * 1024 MAX_METRONINFO_DEPTH = 32 MAX_METRONINFO_NODES = 4096 +MAX_METRONINFO_CREDITS = 128 +MAX_METRONINFO_ROLES = 32 _MAX_ATTRIBUTES = 16 _MAX_FIELD_LENGTH = 4096 _CALENDAR_TIMEZONE = r"(?:Z|[+-](?:(?:0[0-9]|1[0-3]):[0-5][0-9]|14:00))?" @@ -87,6 +89,21 @@ class MetronInfoArc: evidence: ExactIdentityEvidence | None = None +@dataclass(frozen=True) +class MetronInfoResource: + """Descriptive resource with an opaque local ID, never an identity claim.""" + + value: str + resource_id: str | None = None + language: str | None = None + + +@dataclass(frozen=True) +class MetronInfoCredit: + creator: MetronInfoResource + roles: tuple[MetronInfoResource, ...] = () + + @dataclass(frozen=True) class MetronInfoData: series: str | None = None @@ -106,6 +123,26 @@ class MetronInfoData: release_references: tuple[MetronInfoReleaseReference, ...] = () diagnostics: tuple[MetronInfoDiagnostic, ...] = () has_unmapped_content: bool = False + sort_name: str | None = None + language: str | None = None + volume_count: int | None = None + publisher_id: str | None = None + imprint: MetronInfoResource | None = None + alternative_names: tuple[MetronInfoResource, ...] = () + collection_title: str | None = None + manga_volume: str | None = None + summary: str | None = None + notes: str | None = None + page_count: int | None = None + age_rating: str | None = None + story_resources: tuple[MetronInfoResource, ...] = () + genres: tuple[MetronInfoResource, ...] = () + tags: tuple[MetronInfoResource, ...] = () + characters: tuple[MetronInfoResource, ...] = () + teams: tuple[MetronInfoResource, ...] = () + locations: tuple[MetronInfoResource, ...] = () + reprints: tuple[MetronInfoResource, ...] = () + credits: tuple[MetronInfoCredit, ...] | None = None @property def evidence(self) -> tuple[ExactIdentityEvidence, ...]: @@ -224,6 +261,83 @@ def read_text(self, node: ElementTree.Element | None, locator: str) -> str | Non def field(self, parent: ElementTree.Element | None, tag: str, locator: str) -> str | None: return self.read_text(self.single(parent, tag, locator), locator) + def attribute(self, node: ElementTree.Element | None, name: str, locator: str) -> str | None: + if node is None or name not in node.attrib: + return None + value = node.attrib[name] + if len(value) > _MAX_FIELD_LENGTH: + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/@{name}") + return None + self.seen[node].add(name) + return value + + def language(self, node: ElementTree.Element | None, locator: str) -> str | None: + value = self.attribute(node, "lang", locator) + if value is not None and not re.fullmatch(r"[a-z]{2}", value): + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/@lang") + return None + return value + + def resource( + self, node: ElementTree.Element | None, locator: str, *, localized: bool = False + ) -> MetronInfoResource | None: + if node is None: + return None + self.seen.setdefault(node, set()) + value = self.read_text(node, locator) + resource_id = self.attribute(node, "id", locator) + language = self.language(node, locator) if localized else None + if value is None: + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator) + return None + return MetronInfoResource(value, resource_id, language) + + def resources( + self, + parent: ElementTree.Element | None, + group_tag: str, + tag: str, + locator: str, + *, + localized: bool = False, + ) -> tuple[MetronInfoResource, ...]: + group = self.single(parent, group_tag, locator) + if group is None: + return () + result = [] + for index, node in enumerate(group.findall(tag), 1): + item = self.resource(node, f"{locator}/{tag}[{index}]", localized=localized) + if item is not None: + result.append(item) + return tuple(result) + + def read_credits(self) -> tuple[MetronInfoCredit, ...] | None: + group = self.single(self.root, "Credits", "Credits") + if group is None: + return None + diagnostic_count = len(self.diagnostics) + nodes = group.findall("Credit") + if len(nodes) > MAX_METRONINFO_CREDITS: + self.diagnostic(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT, "Credits") + return () + credits = [] + for index, node in enumerate(nodes, 1): + locator = f"Credits/Credit[{index}]" + self.seen[node] = set() + creator = self.resource( + self.single(node, "Creator", f"{locator}/Creator"), f"{locator}/Creator" + ) + if creator is None: + self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/Creator") + roles = self.single(node, "Roles", f"{locator}/Roles") + if roles is not None and len(roles.findall("Role")) > MAX_METRONINFO_ROLES: + self.diagnostic(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT, f"{locator}/Roles") + return () + values = self.resources(node, "Roles", "Role", f"{locator}/Roles") + if creator is not None: + credits.append(MetronInfoCredit(creator, values)) + return tuple(credits) if len(self.diagnostics) == diagnostic_count else () + def integer( self, parent: ElementTree.Element | None, @@ -361,28 +475,20 @@ def read(self) -> MetronInfoData: self.identities[parent] = True if find_exact_identity_conflicts(self.identities): self.diagnostic(MetronInfoDiagnosticCode.EXACT_ID_CONFLICT, "IDS") - stories_group = self.single(self.root, "Stories", "Stories") - stories = [] - if stories_group is not None: - for index, node in enumerate(stories_group.findall("Story"), 1): - self.seen[node] = set() - value = self.read_text(node, f"Stories/Story[{index}]") - if value: - stories.append(value) + stories = self.resources(self.root, "Stories", "Story", "Stories") + publisher = self.single(self.root, "Publisher", "Publisher") data = MetronInfoData( series=self.field(series, "Name", "Series/Name"), number=self.field(self.root, "Number", "Number"), alternative_number=self.field(self.root, "AlternativeNumber", "AlternativeNumber"), - publisher=self.field( - self.single(self.root, "Publisher", "Publisher"), "Name", "Publisher/Name" - ), + publisher=self.field(publisher, "Name", "Publisher/Name"), start_year=self.year(series, "StartYear", "Series/StartYear"), volume=self.integer(series, "Volume", "Series/Volume"), series_format=self.field(series, "Format", "Series/Format"), issue_count=self.integer(series, "IssueCount", "Series/IssueCount", minimum=1), cover_date=self.date("CoverDate"), store_date=self.date("StoreDate"), - stories=tuple(stories), + stories=tuple(item.value for item in stories), arcs=self.read_arcs(primary), primary_source=primary, identities=tuple( @@ -391,6 +497,34 @@ def read(self) -> MetronInfoData: release_references=tuple( MetronInfoReleaseReference(value, flag) for value, flag in self.releases.items() ), + sort_name=self.field(series, "SortName", "Series/SortName"), + language=self.language(series, "Series"), + volume_count=self.integer(series, "VolumeCount", "Series/VolumeCount", minimum=1), + publisher_id=self.attribute(publisher, "id", "Publisher"), + imprint=self.resource( + self.single(publisher, "Imprint", "Publisher/Imprint"), "Publisher/Imprint" + ), + alternative_names=self.resources( + series, + "AlternativeNames", + "AlternativeName", + "Series/AlternativeNames", + localized=True, + ), + collection_title=self.field(self.root, "CollectionTitle", "CollectionTitle"), + manga_volume=self.field(self.root, "MangaVolume", "MangaVolume"), + summary=self.field(self.root, "Summary", "Summary"), + notes=self.field(self.root, "Notes", "Notes"), + page_count=self.integer(self.root, "PageCount", "PageCount"), + age_rating=self.field(self.root, "AgeRating", "AgeRating"), + story_resources=stories, + genres=self.resources(self.root, "Genres", "Genre", "Genres"), + tags=self.resources(self.root, "Tags", "Tag", "Tags"), + characters=self.resources(self.root, "Characters", "Character", "Characters"), + teams=self.resources(self.root, "Teams", "Team", "Teams"), + locations=self.resources(self.root, "Locations", "Location", "Locations"), + reprints=self.resources(self.root, "Reprints", "Reprint", "Reprints"), + credits=self.read_credits(), ) unmapped = any( node not in self.seen diff --git a/tests/fixtures/metroninfo/README.md b/tests/fixtures/metroninfo/README.md index f780f655..bbc57c9b 100644 --- a/tests/fixtures/metroninfo/README.md +++ b/tests/fixtures/metroninfo/README.md @@ -14,6 +14,15 @@ reader, not full schema validation or lossless XML rewriting. The 1.1 fixture includes an unmapped community field deliberately; it must not become canonical issue identity or silently disappear in a later rewrite. +`descriptive.xml` adds credits, opaque creator/role IDs, aliases/languages, +publisher/imprint resources, summary, notes, page count, collection fields and +named resources. `test_metroninfo_descriptive.py` verifies bounded immutable reads, +unknown-content diagnostics, absent versus empty credits, and no partial credit +lists after malformed or over-limit input. Unknown role text remains evidence; +reading it is not a claim that it passes the output schema. Generic resource IDs +do not establish provider identities. Missing language does not invent a user +value from the schema default. + `test_metroninfo.py` covers parsed fields, scoped identities, LOCG discovery references, conflicts, supported encodings, and XML/resource boundaries. `test_archive_metadata.py` builds temporary ZIP, TAR, and 7z archives with diff --git a/tests/fixtures/metroninfo/descriptive.xml b/tests/fixtures/metroninfo/descriptive.xml new file mode 100644 index 00000000..99a38095 --- /dev/null +++ b/tests/fixtures/metroninfo/descriptive.xml @@ -0,0 +1,30 @@ + + + 101202 + Example PressNight Line + + Harbor LightsHarbor Lights, The + 202023 + + Les lumieres + Harbour Lights + + + 50-x150 + Collected Nights02 + New ShoresHomeward + A storm arrives. +The harbor waits. + Reader notes & preserved context.1200 + Teen Plus + Adventure + Coastal + Captain Example + Harbor Watch + The Harbor + First edition + + Alex ExampleWriterArtist + Sam Example + + diff --git a/tests/unit/test_archive_metadata.py b/tests/unit/test_archive_metadata.py index 7ce4f818..efc69278 100644 --- a/tests/unit/test_archive_metadata.py +++ b/tests/unit/test_archive_metadata.py @@ -6,9 +6,10 @@ import random import tarfile import zipfile +from dataclasses import asdict +from pathlib import Path from stat import S_IFLNK from types import SimpleNamespace -from typing import TYPE_CHECKING from unittest.mock import patch import py7zr @@ -19,9 +20,6 @@ from pullbox.core.comicinfo import parse_comicinfo from pullbox.core.metroninfo import parse_metroninfo -if TYPE_CHECKING: - from pathlib import Path - COMICINFO = b"Harbor Lights13a" METRONINFO = b'10113a' @@ -91,6 +89,62 @@ def test_probe_opens_archive_once(tmp_path: Path, suffix: str, backend: str) -> assert opened.call_count == 1 +@pytest.mark.parametrize( + "suffix,backend", + [(".cbz", "zipfile.ZipFile"), (".cbt", "tarfile.open"), (".cb7", "py7zr.SevenZipFile")], +) +def test_rich_metadata_is_read_with_comicinfo_once_without_mutating_read_only_source( + tmp_path: Path, suffix: str, backend: str +) -> None: + payload = (Path(__file__).parents[1] / "fixtures/metroninfo/descriptive.xml").read_bytes() + path = _archive( + tmp_path / f"issue{suffix}", + [("metadata/MetronInfo.xml", payload), ("ComicInfo.xml", COMICINFO), ("001.jpg", b"page")], + ) + original_bytes = path.read_bytes() + path.chmod(0o444) + before = path.stat() + original_files = set(tmp_path.rglob("*")) + module, name = backend.split(".") + original = getattr({"zipfile": zipfile, "tarfile": tarfile, "py7zr": py7zr}[module], name) + with patch(backend, wraps=original) as opened: + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload and result.comicinfo.payload + metron = parse_metroninfo(result.metroninfo.payload) + comic = parse_comicinfo(result.comicinfo.payload.decode()) + assert asdict(metron).get("credits") + assert metron.credits[0].creator.resource_id == "creator:17" + assert metron.page_count == 1200 and metron.stories == ("New Shores", "Homeward") + assert comic and comic.series == metron.series == "Harbor Lights" + assert not metron.diagnostics + assert opened.call_count == 1 + assert path.read_bytes() == original_bytes + assert set(tmp_path.rglob("*")) == original_files + after = path.stat() + assert (after.st_mode, after.st_mtime_ns, after.st_ino) == ( + before.st_mode, + before.st_mtime_ns, + before.st_ino, + ) + + +def test_bad_rich_metadata_does_not_discard_valid_comicinfo(tmp_path: Path) -> None: + path = _archive( + tmp_path / "issue.cbz", + [ + ("ComicInfo.xml", COMICINFO), + ("MetronInfo.xml", b"Broken"), + ], + ) + original = path.read_bytes() + result = ArchiveReader(path).read_metadata_files() + assert result.metroninfo.payload and result.comicinfo.payload + assert parse_metroninfo(result.metroninfo.payload).diagnostics + comic = parse_comicinfo(result.comicinfo.payload.decode()) + assert comic and comic.series == "Harbor Lights" and comic.number == "13a" + assert path.read_bytes() == original + + @pytest.mark.parametrize("suffix", [".cbz", ".cbt", ".cb7"]) def test_missing_optional_metadata_is_not_a_failure(tmp_path: Path, suffix: str) -> None: result = ArchiveReader( diff --git a/tests/unit/test_metroninfo_descriptive.py b/tests/unit/test_metroninfo_descriptive.py new file mode 100644 index 00000000..1d165adc --- /dev/null +++ b/tests/unit/test_metroninfo_descriptive.py @@ -0,0 +1,245 @@ +"""Rich embedded values remain bounded evidence, not provider identity claims.""" + +from dataclasses import FrozenInstanceError, asdict +from pathlib import Path + +import pytest + +from pullbox.core.metroninfo import MetronInfoDiagnosticCode as Code +from pullbox.core.metroninfo import parse_metroninfo + +FIXTURE = Path(__file__).parents[1] / "fixtures" / "metroninfo" / "descriptive.xml" + + +def _xml(extra: str = "", series: str = "Harbor Lights") -> str: + return f"{series}{extra}" + + +def _resource(value: str, resource_id: str | None = None, language: str | None = None) -> dict: + return {"value": value, "resource_id": resource_id, "language": language} + + +def test_rich_document_preserves_descriptive_fields_and_resource_attributes() -> None: + data = parse_metroninfo(FIXTURE.read_bytes()) + values = asdict(data) + expected = { + "sort_name": "Harbor Lights, The", + "language": "fr", + "volume_count": 3, + "publisher_id": "press:7", + "imprint": _resource("Night Line", "imprint:8"), + "alternative_names": ( + _resource("Les lumieres", "name:9", "fr"), + _resource("Harbour Lights"), + ), + "collection_title": "Collected Nights", + "manga_volume": "02", + "summary": "A storm arrives.\nThe harbor waits.", + "notes": "Reader notes & preserved context.", + "page_count": 1200, + "age_rating": "Teen Plus", + "story_resources": (_resource("New Shores", "story:10"), _resource("Homeward")), + "genres": (_resource("Adventure", "genre:11"),), + "tags": (_resource("Coastal", "tag:12"),), + "characters": (_resource("Captain Example", "character:13"),), + "teams": (_resource("Harbor Watch", "team:14"),), + "locations": (_resource("The Harbor", "location:15"),), + "reprints": (_resource("First edition", "issue:16"),), + "credits": ( + { + "creator": _resource("Alex Example", "creator:17"), + "roles": (_resource("Writer", "role:18"), _resource("Artist")), + }, + {"creator": _resource("Sam Example"), "roles": ()}, + ), + } + assert {key: values.get(key) for key in expected} == expected + assert data.stories == ("New Shores", "Homeward") + assert not data.diagnostics and not data.has_unmapped_content + assert { + (item.identity.namespace.value, item.identity.entity_kind.value, item.identity.external_id) + for item in data.evidence + } == {("metron", "issue", "101"), ("comicvine", "issue", "202"), ("metron", "series", "21")} + + +@pytest.mark.parametrize("version", ["", ' version="1.0"', ' version="1.1"']) +def test_rich_read_is_compatible_without_inventing_missing_language_or_credits( + version: str, +) -> None: + data = asdict(parse_metroninfo(_xml().replace("", f""))) + assert "credits" in data and data["credits"] is None + assert "language" in data and data["language"] is None + assert asdict(parse_metroninfo(_xml(""))).get("credits") == () + + +def test_credits_preserve_unknown_roles_and_unclassified_people_without_identity_context() -> None: + data = parse_metroninfo( + _xml( + 'Same Name' + 'Experimental Role' + 'Same Name' + ) + ) + assert asdict(data).get("credits") == ( + { + "creator": _resource("Same Name", "0017"), + "roles": (_resource("Experimental Role", "local-role"),), + }, + {"creator": _resource("Same Name", "0018"), "roles": ()}, + ) + assert data.evidence == () and data.primary_source is None + assert not data.diagnostics + + +@pytest.mark.parametrize( + "field,value,expected", + [ + ("PageCount", "0", 0), + ("PageCount", "1200", 1200), + ("PageCount", "-1", None), + ("PageCount", "1.5", None), + ("PageCount", "2147483648", None), + ], +) +def test_page_counts_are_bounded_nonnegative_integers( + field: str, value: str, expected: int | None +) -> None: + data = parse_metroninfo(_xml(f"<{field}>{value}")) + assert "page_count" in asdict(data) and asdict(data)["page_count"] == expected + assert (Code.INVALID_FIELD in {item.code for item in data.diagnostics}) == (expected is None) + + +@pytest.mark.parametrize( + "xml,locator", + [ + ("firstsecond", "Summary"), + ("", "Credits"), + ( + "AB", + "Credits/Credit[1]/Creator", + ), + ( + "A", + "Credits/Credit[1]/Roles", + ), + ("", "Tags"), + ], +) +def test_duplicate_rich_fields_remain_ambiguous(xml: str, locator: str) -> None: + data = parse_metroninfo(_xml(xml)) + assert (Code.AMBIGUOUS_FIELD, locator) in { + (item.code, item.locator) for item in data.diagnostics + } + assert data.has_unmapped_content + + +@pytest.mark.parametrize( + "xml,locator", + [ + ( + "Writer", + "Credits/Credit[1]/Creator", + ), + (" ", "Credits/Credit[1]/Creator"), + ( + "A", + "Credits/Credit[1]/Creator", + ), + ('A', "Tags/Tag[1]/@id"), + ("" + "x" * 4097 + "", "Summary"), + ], +) +def test_malformed_rich_fields_report_safe_locators(xml: str, locator: str) -> None: + data = parse_metroninfo(_xml(xml)) + assert (Code.INVALID_FIELD, locator) in {(item.code, item.locator) for item in data.diagnostics} + assert "x" * 100 not in repr(data.diagnostics) + + +@pytest.mark.parametrize("lang", ["en-US", "ENG", "E1", "../", ""]) +def test_invalid_series_language_is_reported_not_inferred(lang: str) -> None: + data = parse_metroninfo(_xml(series=f'A')) + assert (Code.INVALID_FIELD, "Series/@lang") in { + (item.code, item.locator) for item in data.diagnostics + } + assert asdict(data).get("language") is None + + +@pytest.mark.parametrize( + "xml", + [ + 'A', + 'A', + 'A', + "AWriterA", + "KnownPreserve or stop", + "Amixed tail", + ], +) +def test_unknown_rich_content_still_prevents_claiming_lossless_rewrite(xml: str) -> None: + data = parse_metroninfo(_xml(xml)) + assert data.has_unmapped_content and Code.UNMAPPED_CONTENT in { + item.code for item in data.diagnostics + } + + +@pytest.mark.parametrize("count", [128, 129]) +def test_credit_limit_rejects_entire_list_instead_of_truncating(count: int) -> None: + data = parse_metroninfo( + _xml("" + "A" * count + "") + ) + assert "credits" in asdict(data) + assert len(asdict(data)["credits"]) == (count if count == 128 else 0) + assert (Code.COMPLEXITY_LIMIT in {item.code for item in data.diagnostics}) == (count > 128) + + +@pytest.mark.parametrize("count", [32, 33]) +def test_role_limit_never_returns_a_truncated_credit(count: int) -> None: + data = parse_metroninfo( + _xml( + "A" + + "Writer" * count + + "" + ) + ) + assert "credits" in asdict(data) + if count == 32: + assert len(asdict(data)["credits"][0]["roles"]) == 32 + else: + assert asdict(data)["credits"] == () + assert Code.COMPLEXITY_LIMIT in {item.code for item in data.diagnostics} + + +def test_rich_data_is_deeply_immutable() -> None: + data = parse_metroninfo(FIXTURE.read_bytes()) + assert asdict(data).get("credits") + with pytest.raises(FrozenInstanceError): + data.credits[0].creator.value = "Other" + + +def test_rich_read_cannot_fetch_resource_ids_or_write_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = FIXTURE.read_bytes() + + def forbidden(*args: object, **kwargs: object) -> None: + pytest.fail("Descriptive metadata attempted external I/O") + + monkeypatch.setattr("builtins.open", forbidden) + monkeypatch.setattr("socket.create_connection", forbidden) + data = parse_metroninfo(payload) + assert data.credits and data.credits[0].creator.resource_id == "creator:17" + assert not data.diagnostics + + +@pytest.mark.parametrize( + "roles", ["Writer", "WriterArtist"] +) +def test_invalid_role_does_not_publish_partial_credit_list(roles: str) -> None: + data = parse_metroninfo( + _xml( + "Good Person" + f"Second Person{roles}" + ) + ) + assert data.credits == () + assert Code.INVALID_FIELD in {item.code for item in data.diagnostics} From 923190bca646392ffc21eb3423f7160802570e89 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 20:49:53 -0700 Subject: [PATCH 59/97] feat(metadata): reconcile embedded archive evidence --- docs/development/ARCHITECTURE_OVERVIEW.md | 12 + src/pullbox/core/metadata_xml.py | 79 ++++ src/pullbox/core/metroninfo.py | 93 +---- .../archive_metadata_reconciliation.py | 370 ++++++++++++++++++ .../test_archive_metadata_reconciliation.py | 349 +++++++++++++++++ 5 files changed, 829 insertions(+), 74 deletions(-) create mode 100644 src/pullbox/core/metadata_xml.py create mode 100644 src/pullbox/services/archive_metadata_reconciliation.py create mode 100644 tests/unit/test_archive_metadata_reconciliation.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index cc9ccd5d..b1621d29 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -461,6 +461,18 @@ in one open pass without changing reference files. This is read support, not offline XSD validation or permission to rewrite either document; coordinated canonical output remains pending. +`services/archive_metadata_reconciliation.py` compares both embedded documents +into canonical-shaped series/issue values without I/O or persistence. It retains +each document and its raw payload, exposes descriptive disagreements separately +from exact-ID conflicts, and leaves disputed shared fields unset. Missing and +explicitly empty credits remain distinct. Partial ComicInfo publication dates +never become invented dates or series start years. Scoped ComicVine markers and +validated resource URLs yield unverified evidence, not attachments or crosswalks. +Both XML paths share the same bounded envelope; duplicate fields and archive +probe diagnostics remain reviewable. This comparison is not write authorization: +canonical adoption, output-schema validation and coordinated atomic writers +remain required before the existing import/writer consumers switch to it. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/core/metadata_xml.py b/src/pullbox/core/metadata_xml.py new file mode 100644 index 00000000..8c761319 --- /dev/null +++ b/src/pullbox/core/metadata_xml.py @@ -0,0 +1,79 @@ +"""Shared bounded XML envelope for embedded metadata, with no external I/O.""" + +from __future__ import annotations + +import io +import re +from xml.etree import ElementTree + +from defusedxml import ElementTree as DefusedElementTree +from defusedxml.common import DefusedXmlException + +from pullbox.core.xml_security import UnsafeXmlError, normalize_xml_for_expat + +MAX_METADATA_XML_BYTES = 2 * 1024 * 1024 +MAX_METADATA_XML_DEPTH = 32 +MAX_METADATA_XML_NODES = 4096 +MAX_METADATA_XML_ATTRIBUTES = 16 + + +class MetadataXmlError(ValueError): + """Safe diagnostic code, never the untrusted XML body.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def parse_metadata_xml(payload: bytes | str, *, root_name: str) -> ElementTree.Element: + if len(payload) > MAX_METADATA_XML_BYTES: + raise MetadataXmlError("too_large") + try: + if isinstance(payload, str) and len(payload.encode("utf-8")) > MAX_METADATA_XML_BYTES: + raise MetadataXmlError("too_large") + normalized = normalize_xml_for_expat(payload) + header = normalized[:512] + if isinstance(header, bytes): + header = header.decode("ascii", errors="ignore") + encoding = re.search(r"<\?xml\b[^>]*\bencoding\s*=\s*['\"]([^'\"]+)", header) + if encoding and encoding[1].lower() not in { + "utf-8", + "utf-16", + "utf-16le", + "utf-16be", + "us-ascii", + }: + raise MetadataXmlError("unsupported_encoding") + stream = io.StringIO(normalized) if isinstance(normalized, str) else io.BytesIO(normalized) + depth = nodes = 0 + root: ElementTree.Element | None = None + iterator = DefusedElementTree.iterparse( + stream, + events=("start", "end"), + forbid_dtd=True, + forbid_entities=True, + forbid_external=True, + ) + for event, node in iterator: + if event == "start": + if root is None: + root = node + depth += 1 + nodes += 1 + if ( + depth > MAX_METADATA_XML_DEPTH + or nodes > MAX_METADATA_XML_NODES + or len(node.attrib) > MAX_METADATA_XML_ATTRIBUTES + ): + raise MetadataXmlError("complexity_limit") + if node.tag.startswith("{http://www.w3.org/2001/XInclude}"): + raise MetadataXmlError("unsafe_xml") + else: + depth -= 1 + except DefusedXmlException as exc: + raise MetadataXmlError("unsafe_xml") from exc + except (ElementTree.ParseError, UnsafeXmlError, UnicodeError, LookupError) as exc: + raise MetadataXmlError("invalid_xml") from exc + if root is None or root.tag != root_name: + raise MetadataXmlError("invalid_root") + return root diff --git a/src/pullbox/core/metroninfo.py b/src/pullbox/core/metroninfo.py index 755d0773..9a947380 100644 --- a/src/pullbox/core/metroninfo.py +++ b/src/pullbox/core/metroninfo.py @@ -3,14 +3,10 @@ from __future__ import annotations import enum -import io import re from dataclasses import dataclass, replace from datetime import date -from xml.etree import ElementTree - -from defusedxml import ElementTree as DefusedElementTree -from defusedxml.common import DefusedXmlException +from typing import TYPE_CHECKING from pullbox.core.metadata_identity import ( ExactIdentityEvidence, @@ -20,17 +16,24 @@ MetadataEntityKind, find_exact_identity_conflicts, ) -from pullbox.core.xml_security import UnsafeXmlError, normalize_xml_for_expat +from pullbox.core.metadata_xml import ( + MAX_METADATA_XML_BYTES, + MAX_METADATA_XML_DEPTH, + MAX_METADATA_XML_NODES, + MetadataXmlError, + parse_metadata_xml, +) + +if TYPE_CHECKING: + from xml.etree import ElementTree -MAX_METRONINFO_BYTES = 2 * 1024 * 1024 -MAX_METRONINFO_DEPTH = 32 -MAX_METRONINFO_NODES = 4096 +MAX_METRONINFO_BYTES = MAX_METADATA_XML_BYTES +MAX_METRONINFO_DEPTH = MAX_METADATA_XML_DEPTH +MAX_METRONINFO_NODES = MAX_METADATA_XML_NODES MAX_METRONINFO_CREDITS = 128 MAX_METRONINFO_ROLES = 32 -_MAX_ATTRIBUTES = 16 _MAX_FIELD_LENGTH = 4096 _CALENDAR_TIMEZONE = r"(?:Z|[+-](?:(?:0[0-9]|1[0-3]):[0-5][0-9]|14:00))?" -_XINCLUDE = "{http://www.w3.org/2001/XInclude}" _SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation" _SOURCES = { "metron": IdentityNamespace.METRON, @@ -152,70 +155,12 @@ def evidence(self) -> tuple[ExactIdentityEvidence, ...]: def parse_metroninfo(payload: bytes | str) -> MetronInfoData: """Read local metadata only; callers retain authority and archive safety checks.""" try: - root = _bounded_tree(payload) - except _ReadError as exc: - return MetronInfoData(diagnostics=(MetronInfoDiagnostic(exc.code, "MetronInfo"),)) - return _Reader(root).read() - - -class _ReadError(ValueError): - def __init__(self, code: MetronInfoDiagnosticCode) -> None: - super().__init__(code.value) - self.code = code - - -def _bounded_tree(payload: bytes | str) -> ElementTree.Element: - if len(payload) > MAX_METRONINFO_BYTES: - raise _ReadError(MetronInfoDiagnosticCode.TOO_LARGE) - try: - if isinstance(payload, str) and len(payload.encode("utf-8")) > MAX_METRONINFO_BYTES: - raise _ReadError(MetronInfoDiagnosticCode.TOO_LARGE) - normalized = normalize_xml_for_expat(payload) - header = normalized[:512] - if isinstance(header, bytes): - header = header.decode("ascii", errors="ignore") - encoding = re.search(r"<\?xml\b[^>]*\bencoding\s*=\s*['\"]([^'\"]+)", header) - if encoding and encoding[1].lower() not in { - "utf-8", - "utf-16", - "utf-16le", - "utf-16be", - "us-ascii", - }: - raise _ReadError(MetronInfoDiagnosticCode.UNSUPPORTED_ENCODING) - stream = io.StringIO(normalized) if isinstance(normalized, str) else io.BytesIO(normalized) - depth = nodes = 0 - root: ElementTree.Element | None = None - iterator = DefusedElementTree.iterparse( - stream, - events=("start", "end"), - forbid_dtd=True, - forbid_entities=True, - forbid_external=True, + root = parse_metadata_xml(payload, root_name="MetronInfo") + except MetadataXmlError as exc: + return MetronInfoData( + diagnostics=(MetronInfoDiagnostic(MetronInfoDiagnosticCode(exc.code), "MetronInfo"),) ) - for event, node in iterator: - if event == "start": - if root is None: - root = node - depth += 1 - nodes += 1 - if ( - depth > MAX_METRONINFO_DEPTH - or nodes > MAX_METRONINFO_NODES - or len(node.attrib) > _MAX_ATTRIBUTES - ): - raise _ReadError(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT) - if node.tag.startswith(_XINCLUDE): - raise _ReadError(MetronInfoDiagnosticCode.UNSAFE_XML) - else: - depth -= 1 - except DefusedXmlException as exc: - raise _ReadError(MetronInfoDiagnosticCode.UNSAFE_XML) from exc - except (ElementTree.ParseError, UnsafeXmlError, UnicodeError, LookupError) as exc: - raise _ReadError(MetronInfoDiagnosticCode.INVALID_XML) from exc - if root is None or root.tag != "MetronInfo": - raise _ReadError(MetronInfoDiagnosticCode.INVALID_ROOT) - return root + return _Reader(root).read() class _Reader: diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py new file mode 100644 index 00000000..78e61ca8 --- /dev/null +++ b/src/pullbox/services/archive_metadata_reconciliation.py @@ -0,0 +1,370 @@ +"""Compare embedded evidence without selecting identities or mutating archives.""" + +import re +from dataclasses import dataclass, field +from datetime import date +from urllib.parse import urlsplit +from xml.etree import ElementTree + +from pydantic import ValidationError + +from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.comicinfo_sanitizer import references_stale_retailer +from pullbox.core.issue_numbers import normalize_issue_number_text +from pullbox.core.metadata_identity import ( + ExactIdentityConflict, + ExactIdentityEvidence, + ExternalIdentityRef, + IdentityEvidenceKind, + IdentityNamespace, + MetadataEntityKind, + find_exact_identity_conflicts, +) +from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml +from pullbox.core.metroninfo import MetronInfoData, parse_metroninfo +from pullbox.schemas.metadata_snapshot import MetadataValues + +_CI_ROLES = { + "Writer": "writer", + "Penciller": "penciller", + "Inker": "inker", + "Colorist": "colorist", + "Letterer": "letterer", + "CoverArtist": "cover", + "Editor": "editor", +} +_CV_HOSTS = frozenset( + {"comicvine.gamespot.com", "www.comicvine.gamespot.com", "comicvine.com", "www.comicvine.com"} +) +_CV_NOTES = ( + (re.compile(r"\[(?:cv_vol_id|cvid):([^\]]*)\]", re.IGNORECASE), MetadataEntityKind.SERIES), + (re.compile(r"\[cv_issue_id:([^\]]*)\]", re.IGNORECASE), MetadataEntityKind.ISSUE), +) + + +@dataclass(frozen=True) +class ArchiveMetadataDiagnostic: + document: str + code: str + locator: str + + +@dataclass(frozen=True) +class EmbeddedMetadataValues: + series: MetadataValues = field(default_factory=MetadataValues) + issue: MetadataValues = field(default_factory=MetadataValues) + evidence: tuple[ExactIdentityEvidence, ...] = () + diagnostics: tuple[ArchiveMetadataDiagnostic, ...] = () + publication_date_parts: tuple[int | None, int | None, int | None] = (None, None, None) + metron: MetronInfoData | None = None + + +@dataclass(frozen=True) +class ArchiveMetadataDifference: + entity: str + field: str + comicinfo: object + metroninfo: object + + +@dataclass(frozen=True) +class ArchiveMetadataReconciliation: + files: ArchiveMetadataFiles + comicinfo: EmbeddedMetadataValues = field(default_factory=EmbeddedMetadataValues) + metroninfo: EmbeddedMetadataValues = field(default_factory=EmbeddedMetadataValues) + series: MetadataValues = field(default_factory=MetadataValues) + issue: MetadataValues = field(default_factory=MetadataValues) + evidence: tuple[ExactIdentityEvidence, ...] = () + identity_conflicts: tuple[ExactIdentityConflict, ...] = () + differences: tuple[ArchiveMetadataDifference, ...] = () + diagnostics: tuple[ArchiveMetadataDiagnostic, ...] = () + + @property + def requires_review(self) -> bool: + return bool(self.identity_conflicts or self.differences or self.diagnostics) + + +def reconcile_archive_metadata(files: ArchiveMetadataFiles) -> ArchiveMetadataReconciliation: + """Return shared values and explicit disagreements, never authority or write permission.""" + comic = _read_document(files.comicinfo, "ComicInfo.xml") + metron = _read_document(files.metroninfo, "MetronInfo.xml") + differences: list[ArchiveMetadataDifference] = [] + series = _shared_values(comic.series, metron.series, "series", differences) + issue = _shared_values(comic.issue, metron.issue, "issue", differences) + if metron.issue.cover_date is not None: + incoming = metron.issue.cover_date + parts = (incoming.year, incoming.month, incoming.day) + if any( + left is not None and left != right + for left, right in zip(comic.publication_date_parts, parts, strict=True) + ): + if not any(item.field == "cover_date" for item in differences): + differences.append( + ArchiveMetadataDifference( + "issue", "cover_date", comic.publication_date_parts, incoming + ) + ) + issue = issue.model_copy(update={"cover_date": None}) + evidence = tuple(dict.fromkeys((*comic.evidence, *metron.evidence))) + return ArchiveMetadataReconciliation( + files, + comic, + metron, + series, + issue, + evidence, + find_exact_identity_conflicts(evidence), + tuple(differences), + (*comic.diagnostics, *metron.diagnostics), + ) + + +def _shared_values( + comic: MetadataValues, + metron: MetadataValues, + entity: str, + differences: list[ArchiveMetadataDifference], +) -> MetadataValues: + values: dict[str, object] = {} + for name in MetadataValues.model_fields: + left, right = getattr(comic, name), getattr(metron, name) + if left is not None and right is not None and left != right: + differences.append(ArchiveMetadataDifference(entity, name, left, right)) + else: + values[name] = left if left is not None else right + return MetadataValues.model_validate(values) + + +def _values( + fields: dict[str, tuple[object, str]], + document: str, + diagnostics: list[ArchiveMetadataDiagnostic], +) -> MetadataValues: + values: dict[str, object] = {} + for name, (value, locator) in fields.items(): + if value is None: + continue + try: + if name == "issue_number_text" and isinstance(value, str): + value = normalize_issue_number_text(value) + normalized = MetadataValues.model_validate({name: value}) + except (ValidationError, ValueError): + diagnostics.append(ArchiveMetadataDiagnostic(document, "invalid_field", locator)) + else: + values[name] = getattr(normalized, name) + return MetadataValues.model_validate(values) + + +def _read_document(member: MetadataFile, document: str) -> EmbeddedMetadataValues: + diagnostics = [ + ArchiveMetadataDiagnostic(document, item.value, document) for item in member.diagnostics + ] + if member.entry_count > 1 and not any(item.code == "duplicate_entries" for item in diagnostics): + diagnostics.append(ArchiveMetadataDiagnostic(document, "duplicate_entries", document)) + if member.payload is None: + if member.entry_count and not diagnostics: + diagnostics.append(ArchiveMetadataDiagnostic(document, "unreadable", document)) + return EmbeddedMetadataValues(diagnostics=tuple(diagnostics)) + if document == "MetronInfo.xml": + return _metron_values(parse_metroninfo(member.payload), diagnostics) + try: + root = parse_metadata_xml(member.payload, root_name="ComicInfo") + except MetadataXmlError as exc: + diagnostics.append(ArchiveMetadataDiagnostic(document, exc.code, "ComicInfo")) + return EmbeddedMetadataValues(diagnostics=tuple(diagnostics)) + return _ComicInfoReader(root, diagnostics).read() + + +def _metron_values( + data: MetronInfoData, diagnostics: list[ArchiveMetadataDiagnostic] +) -> EmbeddedMetadataValues: + document = "MetronInfo.xml" + diagnostics.extend( + ArchiveMetadataDiagnostic(document, item.code.value, item.locator) + for item in data.diagnostics + ) + series = _values( + { + "title": (data.series, "Series/Name"), + "sort_title": (data.sort_name, "Series/SortName"), + "publisher": (data.publisher, "Publisher/Name"), + "language": (data.language, "Series/@lang"), + "year_start": (data.start_year, "Series/StartYear"), + "volume": (str(data.volume) if data.volume is not None else None, "Series/Volume"), + "series_type": (data.series_format, "Series/Format"), + "issue_count": (data.issue_count, "Series/IssueCount"), + }, + document, + diagnostics, + ) + credits = None + if data.credits is not None and not any( + item.locator.startswith("Credits") for item in data.diagnostics + ): + credits = tuple( + {"name": item.creator.value, "role": ", ".join(role.value for role in item.roles)} + for item in data.credits + ) + issue = _values( + { + "issue_number_text": (data.number, "Number"), + "title": (data.stories[0] if len(data.stories) == 1 else None, "Stories"), + "description": (data.summary, "Summary"), + "page_count": (data.page_count, "PageCount"), + "cover_date": (data.cover_date, "CoverDate"), + "store_date": (data.store_date, "StoreDate"), + "credits": (credits, "Credits"), + }, + document, + diagnostics, + ) + return EmbeddedMetadataValues(series, issue, data.evidence, tuple(diagnostics), metron=data) + + +class _ComicInfoReader: + def __init__( + self, root: ElementTree.Element, diagnostics: list[ArchiveMetadataDiagnostic] + ) -> None: + self.root = root + self.diagnostics = diagnostics + self.seen = {root} + self.evidence: list[ExactIdentityEvidence] = [] + + def diagnostic(self, code: str, locator: str) -> None: + item = ArchiveMetadataDiagnostic("ComicInfo.xml", code, locator) + if item not in self.diagnostics: + self.diagnostics.append(item) + + def read_field(self, tag: str) -> str | None: + nodes = self.root.findall(tag) + self.seen.update(nodes) + if len(nodes) > 1: + self.diagnostic("ambiguous_field", tag) + return None + if not nodes: + return None + node = nodes[0] + value = (node.text or "").strip() + if len(node) or len(value) > (200000 if tag == "Summary" else 4096): + self.diagnostic("invalid_field", tag) + return None + return value or None + + def integer(self, tag: str, minimum: int, maximum: int) -> int | None: + value = self.read_field(tag) + if value is None: + return None + if re.fullmatch(r"\+?[0-9]{1,10}", value) and minimum <= int(value) <= maximum: + return int(value) + self.diagnostic("invalid_field", tag) + return None + + def identity(self, kind: MetadataEntityKind, value: str, locator: str) -> None: + try: + identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, value) + except ValueError: + self.diagnostic("invalid_id", locator) + else: + self.evidence.append( + ExactIdentityEvidence(identity, IdentityEvidenceKind.COMICINFO_XML) + ) + + def identities(self) -> None: + # Inspect every explicit assertion even when a duplicate field is ambiguous. + for tag in ("Notes", "Web"): + self.read_field(tag) + for node in self.root.findall(tag): + value = (node.text or "").strip() + if len(node) or len(value) > 4096 or references_stale_retailer(value): + continue + if tag == "Notes": + for pattern, kind in _CV_NOTES: + for match in pattern.finditer(value): + self.identity(kind, match[1], tag) + else: + for raw in value.split(): + self.url_identity(raw) + + def url_identity(self, value: str) -> None: + try: + url = urlsplit(value) + if ( + url.scheme not in {"http", "https"} + or url.hostname not in _CV_HOSTS + or url.username is not None + or url.password is not None + or url.port not in {None, 80, 443} + ): + return + except ValueError: + return + match = re.fullmatch( + r"/(?:(?!(?:4050|4000)-[0-9]+/)[^/]+/)?(4050|4000)-([0-9]{1,255})/?", url.path + ) + if match: + self.identity( + MetadataEntityKind.SERIES if match[1] == "4050" else MetadataEntityKind.ISSUE, + match[2], + "Web", + ) + + def read(self) -> EmbeddedMetadataValues: + document = "ComicInfo.xml" + series = _values( + { + "title": (self.read_field("Series"), "Series"), + "publisher": (self.read_field("Publisher"), "Publisher"), + "language": (self.read_field("LanguageISO"), "LanguageISO"), + }, + document, + self.diagnostics, + ) + year = self.integer("Year", 1, 9999) + month = self.integer("Month", 1, 12) + day = self.integer("Day", 1, 31) + cover_date = None + if year is not None and month is not None and day is not None: + try: + cover_date = date(year, month, day) + except ValueError: + self.diagnostic("invalid_field", "CoverDate") + credits: list[dict[str, str]] = [] + credits_present = False + for tag, role in _CI_ROLES.items(): + credits_present = credits_present or bool(self.root.findall(tag)) + value = self.read_field(tag) + if value: + credits.extend({"name": name.strip(), "role": role} for name in value.split(",")) + invalid_credits = any(item.locator in _CI_ROLES for item in self.diagnostics) + issue = _values( + { + "issue_number_text": (self.read_field("Number"), "Number"), + "title": (self.read_field("Title"), "Title"), + "description": (self.read_field("Summary"), "Summary"), + "page_count": (self.integer("PageCount", 0, 1000000), "PageCount"), + "cover_date": (cover_date, "CoverDate"), + "credits": ( + tuple(credits) if credits_present and not invalid_credits else None, + "Credits", + ), + }, + document, + self.diagnostics, + ) + self.identities() + schema_hint = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation" + if any( + node not in self.seen + or (set(node.attrib) - ({schema_hint} if node is self.root else set())) + or (node is self.root and (node.text or "").strip()) + or (node.tail or "").strip() + for node in self.root.iter() + ): + self.diagnostic("unmapped_content", "ComicInfo") + return EmbeddedMetadataValues( + series, + issue, + tuple(dict.fromkeys(self.evidence)), + tuple(self.diagnostics), + (year, month, day), + ) diff --git a/tests/unit/test_archive_metadata_reconciliation.py b/tests/unit/test_archive_metadata_reconciliation.py new file mode 100644 index 00000000..b1f1799e --- /dev/null +++ b/tests/unit/test_archive_metadata_reconciliation.py @@ -0,0 +1,349 @@ +"""Cross-document agreement is not identity verification or permission to write.""" + +from datetime import date +from pathlib import Path +from unittest.mock import patch +from zipfile import ZipFile + +import pytest + +from pullbox.core.archive import ArchiveReader +from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile, MetadataReadDiagnostic +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata + + +def files(comic: str | bytes | None = None, metron: str | bytes | None = None): + def member(name, value): + return MetadataFile( + name, int(value is not None), value.encode() if isinstance(value, str) else value + ) + + return ArchiveMetadataFiles(member("ComicInfo.xml", comic), member("MetronInfo.xml", metron)) + + +def ci(extra="", series="Harbor Lights", number="050-x"): + return f"{series}{number}{extra}" + + +def mi(extra="", series="Harbor Lights", number="50-X", ids=""): + return ( + f"{ids}{series}" + f"{number}{extra}" + ) + + +def codes(result): + return {(item.document, item.code, item.locator) for item in result.diagnostics} + + +def identities(result): + return { + (item.identity.namespace.value, item.identity.entity_kind.value, item.identity.external_id) + for item in result.evidence + } + + +def test_agreement_and_complementary_values_feed_one_shared_view(): + result = reconcile_archive_metadata( + files( + ci( + "New ShoresReader summary" + "20206Alex Example" + "1200en" + ), + mi( + "New Shores2020-06-01" + "2020-05-27Example Press" + "Alex Example" + "Writer" + ), + ) + ) + assert result.series.title == "Harbor Lights" + assert result.series.publisher == "Example Press" and result.series.language == "en" + assert result.issue.issue_number_text == "50-X" + assert result.issue.title == "New Shores" and result.issue.description == "Reader summary" + assert result.issue.cover_date == date(2020, 6, 1) + assert result.issue.store_date == date(2020, 5, 27) and result.issue.page_count == 1200 + assert result.issue.credits and result.issue.credits[0].name == "Alex Example" + assert result.issue.credits[0].role == "writer" + assert result.comicinfo.publication_date_parts == (2020, 6, None) + assert not result.requires_review + + +@pytest.mark.parametrize( + "kind,notes,ids", + [ + (Kind.ISSUE, "[cv_issue_id:101]", '202'), + ( + Kind.SERIES, + "[cv_vol_id:21]", + '101', + ), + ], +) +def test_exact_conflicts_are_retained_separately_from_descriptive_differences(kind, notes, ids): + metron = mi(ids=ids) + if kind is Kind.SERIES: + metron = metron.replace("", '') + result = reconcile_archive_metadata(files(ci(f"{notes}"), metron)) + assert len(result.identity_conflicts) == 1 + assert result.identity_conflicts[0].entity_kind is kind + assert result.requires_review and not result.differences + assert len(result.identity_conflicts[0].evidence) == 2 + + +def test_same_ids_agree_but_cooccurring_foreign_ids_do_not_become_verified_crosswalks(): + result = reconcile_archive_metadata( + files( + ci("[cv_issue_id:00101] [cv_vol_id:21]"), + mi(ids='10177'), + ) + ) + assert identities(result) == { + ("comicvine", "issue", "101"), + ("comicvine", "series", "21"), + ("metron", "issue", "77"), + } + assert len(result.evidence) == 4 # Keep each source observation, not an attachment. + assert not result.identity_conflicts and not result.requires_review + + +@pytest.mark.parametrize("number", ["13a", "13b", "13c", "50-x", "50-o", "-1", "0.5"]) +def test_exact_designations_survive_reconciliation(number): + result = reconcile_archive_metadata(files(ci(number=number), mi(number=number.upper()))) + assert result.issue.issue_number_text == number.upper() + assert not result.requires_review + + +@pytest.mark.parametrize("number", ["50-O", "50X", "50", "51-X"]) +def test_different_issue_designations_never_collapse_to_numeric_part(number): + result = reconcile_archive_metadata(files(ci(), mi(number=number))) + assert result.issue.issue_number_text is None + assert {(item.entity, item.field) for item in result.differences} == { + ("issue", "issue_number_text") + } + assert not result.identity_conflicts and result.requires_review + + +def test_disagreements_do_not_pick_a_document_and_input_is_retained(): + source = files( + ci("Local edit", series="One"), + mi("Other edit", series="Two"), + ) + result = reconcile_archive_metadata(source) + assert result.series.title is None and result.issue.description is None + assert { + (item.entity, item.field, item.comicinfo, item.metroninfo) for item in result.differences + } == {("series", "title", "One", "Two"), ("issue", "description", "Local edit", "Other edit")} + assert result.files is source and result.comicinfo.issue.description == "Local edit" + assert result.metroninfo.issue.description == "Other edit" and result.requires_review + + +def test_credit_order_is_not_a_difference_but_an_explicit_empty_list_is(): + credits = ( + "BWriter" + "AWriter" + ) + result = reconcile_archive_metadata(files(ci("A, B"), mi(credits))) + assert result.issue.credits and [credit.name for credit in result.issue.credits] == ["A", "B"] + assert not result.requires_review + cleared = reconcile_archive_metadata(files(ci("A"), mi(""))) + assert cleared.issue.credits is None + assert [item.field for item in cleared.differences] == ["credits"] + + +def test_missing_xml_is_optional_and_invalid_xml_does_not_hide_valid_evidence(): + empty = reconcile_archive_metadata(files()) + assert not empty.requires_review + result = reconcile_archive_metadata( + files(ci("[cv_issue_id:101]"), "") + ) + assert result.series.title == "Harbor Lights" + assert identities(result) == {("comicvine", "issue", "101")} + assert ("MetronInfo.xml", "invalid_xml", "MetronInfo") in codes(result) + assert result.requires_review + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://comicvine.gamespot.com/harbor/4050-21/", {("comicvine", "series", "21")}), + ("http://comicvine.com/issue/4000-101/", {("comicvine", "issue", "101")}), + ("https://comicvine.gamespot.com.evil.invalid/4050-21/", set()), + ("https://evil.invalid/?comicvine.gamespot.com/4050-21/", set()), + ("https://comicvine.gamespot.com@evil.invalid/4050-21/", set()), + ("https://user:secret@comicvine.gamespot.com/4050-21/", set()), + ("https://comicvine.gamespot.com:9999/4050-21/", set()), + ("https://comicvine.gamespot.com/4050-21/4000-101/", set()), + ("https://comicvine.gamespot.com/volume/?id=4050-21", set()), + ], +) +def test_comicvine_urls_require_exact_allowed_host_and_resource_path(url, expected): + result = reconcile_archive_metadata(files(ci(f"{url}"))) + assert identities(result) == expected + + +def test_all_duplicate_identity_assertions_are_compared_not_first_or_last_wins(): + result = reconcile_archive_metadata( + files( + ci( + "[cv_issue_id:101] [cv_issue_id:102]" + "[cv_issue_id:103] [cv_vol_id:21]" + ) + ) + ) + assert identities(result) == { + ("comicvine", "issue", "101"), + ("comicvine", "issue", "102"), + ("comicvine", "issue", "103"), + ("comicvine", "series", "21"), + } + assert len(result.identity_conflicts) == 1 + assert ("ComicInfo.xml", "ambiguous_field", "Notes") in codes(result) + + +def test_loose_cvdb_and_volume_fields_never_establish_identity_or_series_year(): + result = reconcile_archive_metadata( + files(ci("[CVDB1132072]209077")) + ) + assert not result.evidence + assert result.series.year_start is None + + +@pytest.mark.parametrize("year,month,day", [(2021, 6, 1), (2020, 7, 1), (2020, 6, 2)]) +def test_partial_calendar_conflicts_remain_visible(year, month, day): + result = reconcile_archive_metadata( + files( + ci(f"{year}{month}{day}"), + mi("2020-06-01"), + ) + ) + assert result.issue.cover_date is None + assert any(item.field == "cover_date" for item in result.differences) + + +def test_date_parts_do_not_invent_a_release_day_or_a_series_start_year(): + result = reconcile_archive_metadata(files(ci("20206"))) + assert result.comicinfo.publication_date_parts == (2020, 6, None) + assert result.issue.cover_date is None and result.series.year_start is None + + +@pytest.mark.parametrize( + "xml,code,locator", + [ + ("A", "invalid_root", "ComicInfo"), + ("", "unsafe_xml", "ComicInfo"), + ( + "AB", + "ambiguous_field", + "Series", + ), + (ci("-1"), "invalid_field", "PageCount"), + (ci("2020230"), "invalid_field", "CoverDate"), + (ci("" + "x" * 200001 + ""), "invalid_field", "Summary"), + (ci("A"), "invalid_field", "Summary"), + (ci("preserve"), "unmapped_content", "ComicInfo"), + (ci('A'), "unmapped_content", "ComicInfo"), + ], +) +def test_comicinfo_review_diagnostics_are_typed_and_do_not_leak_payload(xml, code, locator): + result = reconcile_archive_metadata(files(xml)) + assert ("ComicInfo.xml", code, locator) in codes(result) + assert result.requires_review + assert "x" * 100 not in repr(result.diagnostics) + + +@pytest.mark.parametrize( + "xml,code", + [ + (b"" + b" " * (2 * 1024 * 1024) + b"", "too_large"), + ("" + "" * 33 + "" * 33 + "", "complexity_limit"), + ("" + "" * 4096 + "", "complexity_limit"), + ], +) +def test_comicinfo_uses_the_same_xml_resource_limits_as_metroninfo(xml, code): + assert ("ComicInfo.xml", code, "ComicInfo") in codes(reconcile_archive_metadata(files(xml))) + + +@pytest.mark.parametrize("diagnostic", list(MetadataReadDiagnostic)) +def test_archive_probe_failures_cannot_disappear_in_reconciliation(diagnostic): + source = ArchiveMetadataFiles( + MetadataFile("ComicInfo.xml", 1, diagnostics=(diagnostic,)), MetadataFile("MetronInfo.xml") + ) + result = reconcile_archive_metadata(source) + assert ("ComicInfo.xml", diagnostic.value, "ComicInfo.xml") in codes(result) + assert result.requires_review + + +def test_rich_metron_resources_survive_without_becoming_canonical_identity(): + payload = (Path(__file__).parents[1] / "fixtures/metroninfo/descriptive.xml").read_bytes() + result = reconcile_archive_metadata(files(metron=payload)) + assert result.metroninfo.metron and result.metroninfo.metron.credits + assert result.metroninfo.metron.credits[0].creator.resource_id == "creator:17" + assert result.issue.credits and result.issue.credits[0].role == "artist, writer" + assert result.series.sort_title == "Harbor Lights, The" and result.series.language == "fr" + assert result.issue.title is None # Multiple story titles are not one issue title. + + +def test_real_archive_reconciliation_is_one_read_pass_and_never_writes(tmp_path): + path = tmp_path / "issue.cbz" + with ZipFile(path, "w") as archive: + archive.writestr("ComicInfo.xml", ci("Keep this")) + archive.writestr("MetronInfo.xml", mi()) + original = path.read_bytes() + with patch("zipfile.ZipFile", wraps=ZipFile) as opened: + result = reconcile_archive_metadata(ArchiveReader(path).read_metadata_files()) + assert result.issue.description == "Keep this" and not result.requires_review + assert opened.call_count == 1 and path.read_bytes() == original + + +def test_reconciliation_cannot_call_providers_or_filesystem(monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("Reconciliation attempted external I/O") + + monkeypatch.setattr("socket.create_connection", forbidden) + monkeypatch.setattr("builtins.open", forbidden) + result = reconcile_archive_metadata(files(ci(), mi())) + assert result.series.title == "Harbor Lights" + + +def test_story_arc_ids_do_not_conflict_with_each_other_or_canonical_issue_ids(): + result = reconcile_archive_metadata( + files( + ci("[cv_issue_id:101]"), + mi( + 'First Arc1' + 'Second Arc2', + ids='101', + ), + ) + ) + assert len(result.metroninfo.metron.arcs) == 2 + assert identities(result) == {("comicvine", "issue", "101")} + assert not result.requires_review + + +def test_unsupported_content_and_ambiguous_members_remain_visible_with_valid_values(): + source = files(ci("Preserved"), mi("Preserve too")) + source = ArchiveMetadataFiles( + MetadataFile("ComicInfo.xml", 2, source.comicinfo.payload), source.metroninfo + ) + result = reconcile_archive_metadata(source) + assert result.series.title == "Harbor Lights" and result.issue.description == "Preserved" + assert ("ComicInfo.xml", "duplicate_entries", "ComicInfo.xml") in codes(result) + assert ("MetronInfo.xml", "unmapped_content", "MetronInfo") in codes(result) + assert result.requires_review and result.files is source + + +def test_invalid_credit_list_does_not_become_an_explicit_clear(): + result = reconcile_archive_metadata( + files( + ci("A"), + mi("B"), + ) + ) + assert result.metroninfo.issue.credits is None + assert result.issue.credits and result.issue.credits[0].name == "A" + assert not result.differences and result.requires_review From 3d8d25edec4ab998642d2daca0293523cd7badf4 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 21:24:34 -0700 Subject: [PATCH 60/97] feat(metadata): retain archive provenance in canonical snapshots --- docs/development/ARCHITECTURE_OVERVIEW.md | 18 +- docs/development/DATABASE_STANDARDS.md | 11 + src/pullbox/schemas/metadata_snapshot.py | 13 + src/pullbox/services/metadata_assembly.py | 116 ++++- .../test_archive_metadata_baselines.py | 182 ++++++++ tests/unit/test_archive_metadata_assembly.py | 409 ++++++++++++++++++ 6 files changed, 741 insertions(+), 8 deletions(-) create mode 100644 tests/integration/metadata_identity/test_archive_metadata_baselines.py create mode 100644 tests/unit/test_archive_metadata_assembly.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index b1621d29..f228d8b2 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -469,9 +469,21 @@ explicitly empty credits remain distinct. Partial ComicInfo publication dates never become invented dates or series start years. Scoped ComicVine markers and validated resource URLs yield unverified evidence, not attachments or crosswalks. Both XML paths share the same bounded envelope; duplicate fields and archive -probe diagnostics remain reviewable. This comparison is not write authorization: -canonical adoption, output-schema validation and coordinated atomic writers -remain required before the existing import/writer consumers switch to it. +probe diagnostics remain reviewable. The shared canonical assembler accepts this +comparison only for an independently resolved file/target and parent binding; +it does not perform that matching. Adopted fields retain bounded document-name +provenance, never fabricated API attribution. Local values and explicit empty +credits survive later provider refreshes; user edits/clears remain authoritative. +Unchanged XML that agrees with an existing provider-managed baseline retains its +normal explicit-refresh authority; external XML edits instead require review. +Exact target/parent conflicts and differing issue designations abort assembly. +Descriptive disagreements and unsupported XML remain durable review/preservation +diagnostics rather than becoming provider-filled gaps. Previously observed IDs +survive refreshes without acquiring verified ownership. Existing baseline storage +retains this additive provenance with the same revision/transaction checks. +This comparison and adoption are not write authorization: production file-binding +integration, output-schema validation and coordinated atomic writers remain +required before the existing import/writer consumers switch to them. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index ac471999..65ceb107 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -476,6 +476,17 @@ ComicVine writer likewise resolves known IDs exactly; name-only reuse is limited to unclaimed descriptive rows. No credit schema migration or provenance backfill is needed for this additive snapshot field. +Canonical field origins may retain the fixed embedded document names ComicInfo.xml +and MetronInfo.xml. This local provenance cannot also claim a provider source, +provider freshness, derivation or explicit user override. Older version-one +snapshot JSON without the field remains valid. Archive-derived empty credits are +not provider-enrichment gaps; subsequent edits and clears become user overrides. +Observed archive identities stay unverified and cannot pass the baseline writer's +ownership check as verified identities. Archive review/preservation diagnostics +persist across refreshes until a new archive comparison updates them. The caller +still owns independent file/target matching and atomic application to entity rows; +baseline persistence alone neither establishes that match nor permits file writes. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py index 425a7782..2b903d7f 100644 --- a/src/pullbox/schemas/metadata_snapshot.py +++ b/src/pullbox/schemas/metadata_snapshot.py @@ -9,6 +9,8 @@ from pullbox.schemas.metadata_credits import MetadataCredits from pullbox.schemas.metadata_sources import MetadataDomain +type EmbeddedDocument = Literal["ComicInfo.xml", "MetronInfo.xml"] + def field_domain(kind: MetadataEntityKind, field: str) -> MetadataDomain: if field == "image_url": @@ -52,6 +54,7 @@ class FieldOrigin(BaseModel): observed_at: AwareDatetime user_override: bool = False derivation: Literal["classification", "lifecycle", "catalog", "normalization"] | None = None + embedded_documents: tuple[EmbeddedDocument, ...] = () class MetadataSnapshot(BaseModel): @@ -81,6 +84,16 @@ def consistent_provenance(self) -> Self: or origin.field in fields or origin.domain is not field_domain(self.entity_kind, origin.field) or (origin.user_override and origin.source is not None) + or ( + origin.embedded_documents + and ( + len(set(origin.embedded_documents)) != len(origin.embedded_documents) + or origin.source is not None + or origin.source_updated_at is not None + or origin.user_override + or origin.derivation is not None + ) + ) or ( origin.derivation is not None and (origin.source is not None or origin.user_override) diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py index e4219c57..23c68a7e 100644 --- a/src/pullbox/services/metadata_assembly.py +++ b/src/pullbox/services/metadata_assembly.py @@ -6,6 +6,7 @@ from pullbox.core.issue_numbers import parse_issue_number_text from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource from pullbox.schemas.metadata_snapshot import ( + EmbeddedDocument, FieldOrigin, MetadataSnapshot, MetadataValues, @@ -18,6 +19,7 @@ ProviderStoryArcRead, SourcePolicyRead, ) +from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation from pullbox.services.provider_artwork import allowed_artwork_url type MetadataCandidate = ProviderSeriesRead | ProviderIssueRead | ProviderStoryArcRead @@ -81,12 +83,16 @@ def assemble_metadata( replace_managed: bool = False, parent_identities: Sequence[ExternalIdentityRef] = (), fields: frozenset[str] | None = None, + archive: ArchiveMetadataReconciliation | None = None, ) -> MetadataSnapshot: """Apply authority only after identity agreement; never attach observed crosswalks. Unknown existing values are local, not implicitly provider-managed. A changed or cleared value relative to the last snapshot becomes a durable override. - Callers own proof of the supplied identities and persistence of the result. + Callers own proof of the supplied identities, independent file-to-target and + issue-to-parent matching for archive input, and persistence of the result. + Archive input supplies local values, never proof of those bindings or write + permission. Writers must still preserve unsupported XML and validate output. """ if now.tzinfo is None or now.utcoffset() is None: raise MetadataAssemblyError("Assembly requires an aware observation timestamp.") @@ -102,10 +108,20 @@ def assemble_metadata( policy = {item.source: item for item in policies} if len(policy) != len(policies): raise MetadataAssemblyError("Metadata source policies repeat a source.") - diagnostics: set[str] = set() - evidence = dict(verified) + current = current or (previous.values if previous else MetadataValues()) + diagnostics = { + item + for item in (previous.diagnostics if previous else ()) + if item.startswith("archive:") and archive is None + } + evidence = _identity_map( + (*identities, *(previous.observed_identities if previous else ())), kind + ) normalized: dict[MetadataSource, tuple[MetadataCandidate, MetadataValues]] = {} designations: set[str] = set() + archive_values = _archive_values( + archive, kind, evidence, parents, current, diagnostics, designations + ) for candidate in candidates: source = candidate.source native = ExternalIdentityRef(source.identity_namespace, kind, candidate.external_id) @@ -138,7 +154,6 @@ def assemble_metadata( if len(designations) > 1: raise MetadataAssemblyError("Issue designations disagree. Review the issue match.") - current = current or (previous.values if previous else MetadataValues()) if current.image_url and not allowed_artwork_url(current.image_url): raise MetadataAssemblyError("Existing artwork requires a supported public provider URL.") origins = {item.field: item for item in previous.origins} if previous else {} @@ -161,7 +176,36 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: chosen = prior or FieldOrigin(field=field, domain=domain, observed_at=now) if protected: chosen = FieldOrigin(field=field, domain=domain, observed_at=now, user_override=True) - elif fields is None or field in fields: + elif (fields is None or field in fields) and not any( + f"archive:{kind.value}:{field}:{reason}" in diagnostics + for reason in ("disagreement", "current_disagreement") + ): + local_value = getattr(archive_values, field) + has_local_value = local_value is not None and ( + local_value == () or not missing_metadata_value(local_value) + ) + if archive is not None and has_local_value: + if missing_metadata_value(existing) and not ( + prior is not None and prior.embedded_documents + ): + values[field] = local_value + chosen = FieldOrigin( + field=field, + domain=domain, + observed_at=now, + embedded_documents=_archive_documents(archive, kind, field, local_value), + ) + elif existing != local_value: + diagnostics.add(f"archive:{kind.value}:{field}:current_disagreement") + unchanged_managed = ( + prior is not None + and (prior.source is not None or prior.derivation is not None) + and existing == local_value + ) + if chosen.embedded_documents or (has_local_value and not unchanged_managed): + if not missing_metadata_value(values[field]) or chosen.embedded_documents: + assembled.append(chosen) + continue for source in sorted(normalized, key=lambda source: rank(source, domain)): candidate, incoming = normalized[source] value = getattr(incoming, field) @@ -205,3 +249,65 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: ), diagnostics=tuple(sorted(diagnostics)), ) + + +def _archive_values( + archive: ArchiveMetadataReconciliation | None, + kind: MetadataEntityKind, + evidence: dict[str, ExternalIdentityRef], + parents: dict[str, ExternalIdentityRef], + current: MetadataValues, + diagnostics: set[str], + designations: set[str], +) -> MetadataValues: + if archive is None: + return MetadataValues() + if kind is MetadataEntityKind.STORY_ARC: + raise MetadataAssemblyError("Issue archive metadata cannot assign a story arc.") + if archive.identity_conflicts: + raise MetadataAssemblyError("Exact identities disagree. Review the match.") + if any(item.code != "unmapped_content" for item in archive.diagnostics): + raise MetadataAssemblyError("Embedded metadata needs review before adoption.") + for observation in archive.evidence: + claim = observation.identity + target = evidence if claim.entity_kind is kind else parents + if claim.namespace in target and target[claim.namespace] != claim: + raise MetadataAssemblyError("Exact identities disagree. Review the match.") + if claim.entity_kind is kind: + evidence[claim.namespace] = claim + diagnostics.update(f"archive:{item.document}:{item.code}" for item in archive.diagnostics) + diagnostics.update( + f"archive:{item.entity}:{item.field}:disagreement" for item in archive.differences + ) + if kind is MetadataEntityKind.ISSUE: + if current.cover_date is not None: + saved_date = current.cover_date + if any( + part is not None and part != saved + for part, saved in zip( + archive.comicinfo.publication_date_parts, + (saved_date.year, saved_date.month, saved_date.day), + strict=True, + ) + ): + diagnostics.add("archive:issue:cover_date:current_disagreement") + for values in (current, archive.comicinfo.issue, archive.metroninfo.issue): + number = values.issue_number_text + if number is not None: + try: + _, designation = parse_issue_number_text(number) + except ValueError as exc: + raise MetadataAssemblyError("Unsupported issue designation.") from exc + designations.add(designation.casefold()) + return archive.issue + return archive.series + + +def _archive_documents( + archive: ArchiveMetadataReconciliation, kind: MetadataEntityKind, field: str, value: object +) -> tuple[EmbeddedDocument, ...]: + sources: tuple[tuple[EmbeddedDocument, MetadataValues], ...] = ( + ("ComicInfo.xml", getattr(archive.comicinfo, kind.value)), + ("MetronInfo.xml", getattr(archive.metroninfo, kind.value)), + ) + return tuple(name for name, fields in sources if getattr(fields, field) == value) diff --git a/tests/integration/metadata_identity/test_archive_metadata_baselines.py b/tests/integration/metadata_identity/test_archive_metadata_baselines.py new file mode 100644 index 00000000..a64c7bf7 --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_metadata_baselines.py @@ -0,0 +1,182 @@ +"""Archive provenance persists through the real baseline and refresh boundaries.""" + +from datetime import UTC, datetime +from zipfile import ZipFile + +import pytest +from sqlalchemy import func, select, update + +from pullbox.core.archive_metadata import read_archive_metadata +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.models import Issue, IssueExternalIdentity +from pullbox.schemas.metadata_snapshot import MetadataValues +from pullbox.schemas.metadata_sources import ProviderIssueRead +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import ( + MetadataBaselineConflictError, + MetadataBaselineWrite, + load_metadata_baseline, + save_metadata_baselines, +) +from tests.integration.metadata_identity.test_metadata_baselines import seed +from tests.unit.test_metadata_discovery import runtime + +NOW = datetime(2026, 9, 28, 12, tzinfo=UTC) +ISSUE = ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, "42") +PARENT = ExternalIdentityRef(Namespace.METRON, Kind.SERIES, "12") +OBSERVED = ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "7") + + +def real_archive(tmp_path): + path = tmp_path / "local.cbz" + with ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"fixture page bytes") + archive.writestr( + "ComicInfo.xml", + "50-xLocal title" + "[cv_issue_id:7]Preserve me", + ) + archive.writestr( + "MetronInfo.xml", "Local summary" + ) + path.chmod(0o444) + before = path.read_bytes(), path.stat() + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1024) + ) + value = assemble_metadata( + Kind.ISSUE, (ISSUE,), [], [], now=NOW, archive=evidence, parent_identities=(PARENT,) + ) + return path, before, value + + +def refreshed(saved, current): + candidate = ProviderIssueRead( + source=Source.METRON_API, + identity_namespace=Namespace.METRON, + external_id="42", + series_external_id="12", + issue_number_text="50-x", + title="Provider replacement", + description="Provider summary", + credits=({"name": "Provider creator", "role": "writer"},), + ) + return assemble_metadata( + Kind.ISSUE, + (ISSUE,), + [candidate], + [runtime(Source.METRON_API).policy], + now=NOW, + current=current, + previous=saved.snapshot, + parent_identities=(PARENT,), + replace_managed=True, + ) + + +async def test_readonly_archive_provenance_survives_database_and_provider_roundtrip( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + local_id = await seed(factory, Kind.ISSUE) + path, (original, stat), value = real_archive(tmp_path) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, value)]) + await engine.dispose() + async with factory.begin() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + assert saved.snapshot == value and saved.revision == 1 + after = refreshed(saved, saved.snapshot.values) + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, after, 1)]) + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + assert saved.snapshot.values.title == "Local title" + assert saved.snapshot.values.description == "Local summary" + assert saved.snapshot.values.credits == () + assert saved.snapshot.identities == (ISSUE,) + assert saved.snapshot.observed_identities == (OBSERVED,) + assert "archive:ComicInfo.xml:unmapped_content" in saved.snapshot.diagnostics + assert await session.scalar(select(func.count()).select_from(IssueExternalIdentity)) == 1 + assert path.read_bytes() == original + assert (path.stat().st_ino, path.stat().st_mtime_ns, path.stat().st_mode) == ( + stat.st_ino, + stat.st_mtime_ns, + stat.st_mode, + ) + + +@pytest.mark.parametrize("title", ["My corrected title", "", None]) +async def test_persisted_archive_value_can_be_user_edited_or_cleared( + identity_probe_db, tmp_path, title +): + _, factory, _ = identity_probe_db + local_id = await seed(factory, Kind.ISSUE) + _, _, value = real_archive(tmp_path) + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, value)]) + await session.execute(update(Issue).where(Issue.id == local_id).values(title=title)) + async with factory.begin() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + row = await session.get(Issue, local_id) + current = saved.snapshot.values.model_copy(update={"title": row.title}) + after = refreshed(saved, current) + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, after, 1)]) + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + assert saved.snapshot.values.title == title + origin = next(item for item in saved.snapshot.origins if item.field == "title") + assert origin.user_override and origin.embedded_documents == () + + +async def test_archive_baseline_cannot_promote_observation_or_hide_stale_revision( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + local_id = await seed(factory, Kind.ISSUE) + _, _, value = real_archive(tmp_path) + async with factory() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, value)]) + await session.rollback() + async with factory() as session: + assert await load_metadata_baseline(session, Kind.ISSUE, local_id) is None + async with factory.begin() as session: + await save_metadata_baselines(session, [MetadataBaselineWrite(local_id, value)]) + for snapshot, revision in ( + (value, 0), + (value.model_copy(update={"identities": (ISSUE, OBSERVED), "observed_identities": ()}), 1), + ): + async with factory.begin() as session: + with pytest.raises(MetadataBaselineConflictError): + await save_metadata_baselines( + session, [MetadataBaselineWrite(local_id, snapshot, revision)] + ) + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + assert saved.revision == 1 and saved.snapshot == value + + +async def test_existing_snapshot_without_archive_field_loads_unchanged(identity_probe_db): + import json + + from pullbox.models import IssueMetadataBaseline + + _, factory, _ = identity_probe_db + local_id = await seed(factory, Kind.ISSUE) + value = assemble_metadata( + Kind.ISSUE, (ISSUE,), [], [], now=NOW, current=MetadataValues(title="Legacy") + ) + legacy = value.model_dump(mode="json") + for origin in legacy["origins"]: + origin.pop("embedded_documents", None) + async with factory.begin() as session: + session.add( + IssueMetadataBaseline(issue_id=local_id, revision=1, snapshot_json=json.dumps(legacy)) + ) + async with factory() as session: + saved = await load_metadata_baseline(session, Kind.ISSUE, local_id) + assert saved.snapshot.values.title == "Legacy" + assert saved.snapshot.origins[0].embedded_documents == () diff --git a/tests/unit/test_archive_metadata_assembly.py b/tests/unit/test_archive_metadata_assembly.py new file mode 100644 index 00000000..74f36eab --- /dev/null +++ b/tests/unit/test_archive_metadata_assembly.py @@ -0,0 +1,409 @@ +"""Archive values join canonical assembly without acquiring provider authority.""" + +from datetime import UTC, date, datetime, timedelta + +import pytest +from pydantic import ValidationError + +from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity import MetadataSource as Source +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import MetadataDomain, ProviderIssueRead +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.metadata_assembly import MetadataAssemblyError, assemble_metadata +from tests.unit.test_metadata_discovery import runtime + +NOW = datetime(2026, 9, 28, 12, tzinfo=UTC) +CV = ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "7") +PARENT = ExternalIdentityRef(Namespace.COMICVINE, Kind.SERIES, "42") +METRON = ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, "8") + + +def embedded(ci="", mi=""): + return reconcile_archive_metadata( + ArchiveMetadataFiles( + comicinfo=MetadataFile( + name="ComicInfo.xml", + payload=f"{ci}".encode() if ci is not None else None, + entry_count=1 if ci is not None else 0, + ), + metroninfo=MetadataFile( + name="MetronInfo.xml", + payload=f"{mi}".encode() if mi is not None else None, + entry_count=1 if mi is not None else 0, + ), + ) + ) + + +def candidate(**changes): + values = ProviderIssueRead( + source=Source.COMICVINE_API, + identity_namespace=Namespace.COMICVINE, + external_id="7", + series_external_id="42", + issue_number_text="50-x", + title="Provider title", + ).model_dump() + return ProviderIssueRead.model_validate(values | changes) + + +def assemble(archive=None, **kwargs): + return assemble_metadata( + Kind.ISSUE, + (CV,), + kwargs.pop("candidates", []), + [runtime(Source.COMICVINE_API).policy], + now=NOW, + parent_identities=(PARENT,), + archive=archive, + **kwargs, + ) + + +def origin(snapshot, field): + return next(item for item in snapshot.origins if item.field == field) + + +def test_archive_gaps_use_honest_document_provenance_not_provider_or_user_authority(): + archive = embedded( + "Local title50-xLocal summary", + "Local title28", + ) + result = assemble(archive, candidates=[candidate()]) + assert result.values.title == "Local title" + assert result.values.description == "Local summary" + assert result.values.page_count == 28 + assert origin(result, "title").embedded_documents == ("ComicInfo.xml", "MetronInfo.xml") + assert origin(result, "description").embedded_documents == ("ComicInfo.xml",) + assert origin(result, "page_count").embedded_documents == ("MetronInfo.xml",) + for field in ("title", "description", "page_count"): + provenance = origin(result, field) + assert provenance.source is None and not provenance.user_override + assert provenance.derivation is None and provenance.source_updated_at is None + assert provenance.observed_at == NOW + assert MetadataSnapshot.model_validate_json(result.model_dump_json()) == result + + +def test_series_archive_values_flow_through_the_same_assembler(): + archive = embedded( + "Local seriesLocal publisher", + "Local series1986", + ) + result = assemble_metadata(Kind.SERIES, (PARENT,), [], [], now=NOW, archive=archive) + assert result.values.title == "Local series" and result.values.year_start == 1986 + assert origin(result, "title").embedded_documents == ("ComicInfo.xml", "MetronInfo.xml") + + +@pytest.mark.parametrize("cleared", ["My title", "", None]) +def test_edits_and_clears_survive_archive_adoption_and_subsequent_provider_refresh(cleared): + before = assemble(candidates=[candidate()]) + current = before.values.model_copy(update={"title": cleared}) + result = assemble( + embedded("XML title"), + previous=before, + current=current, + candidates=[candidate()], + replace_managed=True, + ) + assert result.values.title == cleared and origin(result, "title").user_override + assert origin(result, "title").embedded_documents == () + again = assemble(previous=result, candidates=[candidate()], replace_managed=True) + assert again.values.title == cleared and origin(again, "title").user_override + + +@pytest.mark.parametrize("xml", ["", None]) +def test_explicit_empty_credits_are_not_an_enrichment_gap(xml): + archive = embedded(xml, "" if xml is None else None) + result = assemble( + archive, candidates=[candidate(credits=({"name": "Provider", "role": "writer"},))] + ) + assert result.values.credits == () + assert origin(result, "credits").embedded_documents + result = MetadataSnapshot.model_validate_json(result.model_dump_json()) + later = assemble( + previous=result, + candidates=[candidate(credits=({"name": "Provider", "role": "writer"},))], + replace_managed=True, + ) + assert later.values.credits == () + assert origin(later, "credits") == origin(result, "credits") + + +def test_absent_credits_do_not_prevent_provider_gap_fill(): + result = assemble( + embedded(), candidates=[candidate(credits=({"name": "Provider", "role": "writer"},))] + ) + assert result.values.credits[0].name == "Provider" + assert origin(result, "credits").source is Source.COMICVINE_API + + +def test_archive_values_remain_local_on_explicit_provider_refresh(): + before = assemble(embedded("My embedded title")) + later = assemble(previous=before, candidates=[candidate()], replace_managed=True) + assert later.values.title == "My embedded title" + assert origin(later, "title") == origin(before, "title") + + +def test_existing_values_are_not_relabelled_as_archive_or_silently_replaced(): + current = MetadataValues(title="Existing", description="") + result = assemble( + embedded("EmbeddedNew summary"), + current=current, + replace_managed=True, + ) + assert result.values.title == "Existing" + assert result.values.description == "New summary" + assert not origin(result, "title").embedded_documents + assert "archive:issue:title:current_disagreement" in result.diagnostics + + +def test_xml_disagreements_remain_unresolved_even_when_a_provider_could_fill_the_field(): + archive = embedded( + "First", "Second24" + ) + result = assemble(archive, candidates=[candidate()]) + assert result.values.title is None and result.values.page_count == 24 + assert "archive:issue:title:disagreement" in result.diagnostics + later = assemble(previous=result, candidates=[candidate()], replace_managed=True) + assert later.values.title is None + assert "archive:issue:title:disagreement" in later.diagnostics + corrected = assemble(embedded("Corrected"), previous=later) + assert corrected.values.title == "Corrected" + assert "archive:issue:title:disagreement" not in corrected.diagnostics + + +def test_field_scope_does_not_erase_archive_or_local_provenance(): + result = assemble( + embedded("LocalSummary"), + fields=frozenset({"description"}), + ) + assert result.values.title is None and result.values.description == "Summary" + + +@pytest.mark.parametrize("notes", ["[cv_issue_id:9]", "[cv_vol_id:99]"]) +def test_archive_exact_conflict_stops_all_fields_even_if_outside_requested_scope(notes): + with pytest.raises(MetadataAssemblyError, match="identit"): + assemble( + embedded(f"{notes}Should not apply"), + fields=frozenset({"description"}), + ) + + +def test_xml_internal_identity_conflict_cannot_be_hidden_by_empty_fields(): + archive = embedded( + "[cv_issue_id:7]", '9' + ) + assert archive.identity_conflicts + with pytest.raises(MetadataAssemblyError, match="identit"): + assemble(archive, fields=frozenset()) + + +def test_foreign_archive_ids_are_observed_not_attached_and_survive_refresh(): + archive = embedded("[cv_issue_id:7]", '8') + result = assemble(archive) + assert result.identities == (CV,) and result.observed_identities == (METRON,) + later = assemble(previous=result, candidates=[candidate()]) + assert later.observed_identities == (METRON,) and later.identities == (CV,) + + +def test_prior_observed_identity_cannot_disappear_or_be_contradicted_on_refresh(): + prior = assemble().model_copy(update={"observed_identities": (METRON,)}) + assert assemble(previous=prior).observed_identities == (METRON,) + conflicting = ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, "99") + with pytest.raises(MetadataAssemblyError, match="identit"): + assemble(previous=prior, candidates=[candidate(cross_identities=[conflicting])]) + + +@pytest.mark.parametrize("number", ["50-o", "50x", "50", "51-x"]) +def test_archive_issue_number_cannot_change_the_resolved_target(number): + with pytest.raises(MetadataAssemblyError, match="designation"): + assemble( + embedded(f"{number}"), current=MetadataValues(issue_number_text="50-x") + ) + + +def test_archive_and_provider_designations_must_agree_before_any_field_is_applied(): + with pytest.raises(MetadataAssemblyError, match="designation"): + assemble( + embedded("50-oLocal"), candidates=[candidate()] + ) + + +@pytest.mark.parametrize("number", ["50-X", "050-x"]) +def test_equivalent_issue_designations_preserve_source_text(number): + result = assemble(embedded(f"{number}"), candidates=[candidate()]) + assert result.values.issue_number_text.casefold() == "50-x" + + +@pytest.mark.parametrize( + "ci,mi", + [ + ("invalid", ""), + ("<Bad/>", ""), + ("", ""), + ], +) +def test_invalid_xml_stops_adoption_instead_of_using_the_other_document(ci, mi): + archive = embedded(ci, mi) + assert archive.diagnostics + with pytest.raises(MetadataAssemblyError, match="review"): + assemble(archive, candidates=[candidate()]) + + +def test_unsupported_xml_is_retained_as_a_durable_preservation_requirement(): + result = assemble(embedded("LocalKeep me")) + assert result.values.title == "Local" + assert "archive:ComicInfo.xml:unmapped_content" in result.diagnostics + later = assemble(previous=result, candidates=[candidate()]) + assert "archive:ComicInfo.xml:unmapped_content" in later.diagnostics + assert "Keep me" not in result.model_dump_json() + + +def test_story_arc_membership_cannot_be_assigned_from_an_issue_archive(): + with pytest.raises(MetadataAssemblyError, match="archive"): + assemble_metadata(Kind.STORY_ARC, (), [], [], now=NOW, archive=embedded()) + + +@pytest.mark.parametrize( + "attributes", + [ + {"source": Source.COMICVINE_API}, + {"derivation": "catalog"}, + {"user_override": True}, + {"source_updated_at": NOW}, + ], +) +def test_embedded_provenance_cannot_claim_provider_user_or_derived_authority(attributes): + with pytest.raises(ValidationError): + MetadataSnapshot( + entity_kind=Kind.ISSUE, + identities=(CV,), + values=MetadataValues(title="Archive"), + origins=( + FieldOrigin( + field="title", + domain=MetadataDomain.CORE, + observed_at=NOW, + embedded_documents=("ComicInfo.xml",), + **attributes, + ), + ), + ) + + +def test_old_snapshot_payloads_remain_compatible_and_document_origin_is_bounded(): + legacy = assemble(candidates=[candidate()]).model_dump(mode="json") + for field in legacy["origins"]: + field.pop("embedded_documents", None) + restored = MetadataSnapshot.model_validate(legacy) + assert origin(restored, "title").embedded_documents == () + for documents in (("ComicInfo.xml", "ComicInfo.xml"), ("/secret/path",)): + with pytest.raises(ValidationError): + MetadataSnapshot( + entity_kind=Kind.ISSUE, + identities=(CV,), + values=MetadataValues(title="Archive"), + origins=( + FieldOrigin( + field="title", + domain=MetadataDomain.CORE, + observed_at=NOW, + embedded_documents=documents, + ), + ), + ) + + +def test_fresh_archive_read_updates_only_its_own_evidence_not_observation_clock(): + before = assemble(embedded("Original")) + later = assemble_metadata( + Kind.ISSUE, + (CV,), + [], + [], + now=NOW + timedelta(days=1), + previous=before, + archive=embedded("Original"), + parent_identities=(PARENT,), + ) + assert origin(later, "title") == origin(before, "title") + + +def test_partial_archive_date_cannot_be_silently_replaced_by_a_different_canonical_date(): + result = assemble( + embedded("19976"), + current=MetadataValues(cover_date=date(1998, 6, 1)), + ) + assert result.values.cover_date == date(1998, 6, 1) + assert "archive:issue:cover_date:current_disagreement" in result.diagnostics + + +def test_partial_date_agreement_does_not_invent_archive_provenance_for_a_full_date(): + result = assemble( + embedded("1997"), + current=MetadataValues(cover_date=date(1997, 6, 1)), + ) + assert not result.diagnostics and not origin(result, "cover_date").embedded_documents + + +def test_xml_issue_number_disagreement_stops_adoption_without_a_provider_candidate(): + with pytest.raises(MetadataAssemblyError, match="designation"): + assemble(embedded("13a", "13b")) + + +def test_observed_archive_id_cannot_become_a_verified_snapshot_identity(): + result = assemble_metadata( + Kind.ISSUE, + (), + [], + [], + now=NOW, + archive=embedded("[cv_issue_id:7]Local"), + ) + assert result.identities == () and result.observed_identities == (CV,) + + +def test_requested_field_scope_cannot_bypass_invalid_archive_metadata(): + with pytest.raises(MetadataAssemblyError, match="review"): + assemble(embedded("<Title/>"), fields=frozenset()) + + +@pytest.mark.parametrize("manual", [False, True]) +def test_unchanged_provider_managed_xml_keeps_existing_refresh_authority(manual): + before = assemble(candidates=[candidate(title="Original")]) + result = assemble( + embedded("<Title>Original"), + previous=before, + candidates=[candidate(title="Refreshed")], + replace_managed=manual, + ) + assert result.values.title == ("Refreshed" if manual else "Original") + assert origin(result, "title").source is Source.COMICVINE_API + assert not origin(result, "title").embedded_documents and not result.diagnostics + + +def test_changed_provider_managed_xml_still_requires_review_before_replacement(): + before = assemble(candidates=[candidate(title="Original")]) + result = assemble( + embedded("Edited in another application"), + previous=before, + candidates=[candidate(title="Refreshed")], + replace_managed=True, + ) + assert result.values.title == "Original" + assert "archive:issue:title:current_disagreement" in result.diagnostics + + +def test_agreement_with_unknown_local_data_does_not_make_it_provider_managed(): + before = assemble(current=MetadataValues(title="Local")) + result = assemble( + embedded("Local"), + previous=before, + candidates=[candidate(title="Refreshed")], + replace_managed=True, + ) + assert result.values.title == "Local" and origin(result, "title").source is None From f7ed67e7c100d64e15230fc73ed58510c4040141 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Mon, 28 Sep 2026 22:14:17 -0700 Subject: [PATCH 61/97] feat(metadata): validate MetronInfo output against offline schema --- .gitleaksignore | 7 +- docs/development/ARCHITECTURE_OVERVIEW.md | 12 +- docs/development/INFRASTRUCTURE.md | 4 + pyproject.toml | 4 + .../core/metadata_schemas/METRONINFO-LICENSE | 21 ++ .../core/metadata_schemas/MetronInfo-1.1.xsd | 355 ++++++++++++++++++ src/pullbox/core/metadata_schemas/README.md | 27 ++ src/pullbox/core/metroninfo_schema.py | 45 +++ tests/unit/test_metroninfo_schema.py | 215 +++++++++++ 9 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 src/pullbox/core/metadata_schemas/METRONINFO-LICENSE create mode 100644 src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd create mode 100644 src/pullbox/core/metadata_schemas/README.md create mode 100644 src/pullbox/core/metroninfo_schema.py create mode 100644 tests/unit/test_metroninfo_schema.py diff --git a/.gitleaksignore b/.gitleaksignore index 0d9bbeb4..8c90ce14 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -3,9 +3,10 @@ tests/api/test_comicvine_key.py:generic-api-key:253 tests/integration/test_security_endpoints.py:generic-api-key:206 tests/unit/test_secret_validation.py:generic-api-key:103 tests/fixtures/prowlarr_test_fixtures.json:square-access-token:206168 -tests/unit/test_import_story_arc_staging.py:generic-api-key:456 -tests/unit/test_import_story_arc_staging.py:generic-api-key:468 -tests/unit/test_import_story_arc_staging.py:generic-api-key:480 +# Synthetic folder cohort paths; exact locations shifted with the test's imports. +tests/unit/test_import_story_arc_staging.py:generic-api-key:457 +tests/unit/test_import_story_arc_staging.py:generic-api-key:469 +tests/unit/test_import_story_arc_staging.py:generic-api-key:481 # Historical synthetic folder cohort IDs, not credentials. Keep commit/path/line scope. ecd22c27eb7607e072ddb98d921738fb213896e4:tests/unit/test_import_scan_pipeline.py:generic-api-key:502 ecd22c27eb7607e072ddb98d921738fb213896e4:tests/unit/test_import_story_arc_staging.py:generic-api-key:443 diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index f228d8b2..73cea69d 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -482,8 +482,16 @@ diagnostics rather than becoming provider-filled gaps. Previously observed IDs survive refreshes without acquiring verified ownership. Existing baseline storage retains this additive provenance with the same revision/transaction checks. This comparison and adoption are not write authorization: production file-binding -integration, output-schema validation and coordinated atomic writers remain -required before the existing import/writer consumers switch to them. +integration and coordinated atomic writers remain required before the existing +import/writer consumers switch to them. + +`core/metroninfo_schema.py` provides the offline output-schema gate. It validates +a bounded, defused document with the bundled, pinned MetronInfo 1.1 XSD through +`XMLSchema11`, including its primary-ID/URL assertions. Schema location hints +cannot trigger network or filesystem access. Validation errors expose fixed +codes, not XML payloads. The schema, MIT license and provenance ship in the +Python package. This gate checks representation, not identity or write authority; +canonical paired rendering and archive integration are still required. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/INFRASTRUCTURE.md b/docs/development/INFRASTRUCTURE.md index 279a0fb1..587e1d3a 100644 --- a/docs/development/INFRASTRUCTURE.md +++ b/docs/development/INFRASTRUCTURE.md @@ -604,6 +604,7 @@ Production dependency categories: | Auth/security | bcrypt, itsdangerous, cryptography | | Logging/tasks | structlog, APScheduler | | Archive/media | rarfile, py7zr, Pillow, pdf2image, tzlocal | +| Embedded metadata | defusedxml for bounded parsing; xmlschema for offline MetronInfo XSD 1.1 validation | Development dependency categories: @@ -619,6 +620,9 @@ Development dependency categories: - Keep production dependencies minimal. - Every production dependency should justify its runtime value. +- MetronInfo's bundled schema requires XSD 1.1 assertions. Keep its schema, + upstream license, and provenance in the built package; validation must not + download schemas or follow document-supplied resource locations. - Prefer standard library tools when they are good enough. - Use compatible-release pinning where practical. - Avoid unnecessary dependency churn. diff --git a/pyproject.toml b/pyproject.toml index a1f74e8f..8a838a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "cryptography>=50.0.0", # floor for CVE-2026-69247 "msgpack>=1.2.1,<2.0", # transitive via py7zr — floor for GHSA-6v7p-g79w-8964 "defusedxml~=0.7", + "xmlschema~=4.3.2", # Offline MetronInfo XSD 1.1 validation, including assertions. "apscheduler~=3.10", "starlette>=1.3.1", # transitive via fastapi — floors for PYSEC-2026-161/CVE-2026-54282/CVE-2026-54283 "structlog>=25.5,<27.0", @@ -92,6 +93,9 @@ where = ["src"] [tool.setuptools.package-data] pullbox = [ + "core/metadata_schemas/*.xsd", + "core/metadata_schemas/*-LICENSE", + "core/metadata_schemas/README.md", "ui/templates/*.html", "ui/templates/components/*.html", "ui/templates/pages/*.html", diff --git a/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE b/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE new file mode 100644 index 00000000..6fab07ec --- /dev/null +++ b/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Brian Pepple + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd b/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd new file mode 100644 index 00000000..220312b1 --- /dev/null +++ b/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pullbox/core/metadata_schemas/README.md b/src/pullbox/core/metadata_schemas/README.md new file mode 100644 index 00000000..2ef6238e --- /dev/null +++ b/src/pullbox/core/metadata_schemas/README.md @@ -0,0 +1,27 @@ +# Embedded Metadata Schemas + +`MetronInfo-1.1.xsd` is the unmodified MetronInfo 1.1 schema from +[Metron-Project/metroninfo](https://github.com/Metron-Project/metroninfo), pinned +at commit `77b9fdbe489568b9e047206005e7f6b0700bb3f0`, path +`schema/v1.1/MetronInfo.xsd`. The upstream MIT license is included as +`METRONINFO-LICENSE`. + +SHA-256: `c0af59fc39e17e32c1a01714edc42d920bddc9d14e880ffc55ab960b8243a092`. + +These files ship in the Python package. Validation uses `xmlschema.XMLSchema11` +offline; do not replace it with an XSD 1.0 validator or omit `xs:assert` rules. +The schema is not downloaded at runtime. Instance schema hints, remote resource +locations, and external entity expansion must never be followed. + +`xmlschema` 4.3.2 incorrectly counts explicit `primary="false"` / `primary="0"` +attributes in these assertion contexts. The write validator normalizes valid +false flags to absence only on its private parsed `IDS/ID` and `URLs/URL` nodes. +Those flags are optional and have no default in the pinned schema, so this does +not change their meaning. The source XML and upstream schema stay unchanged; +true flags, malformed booleans, required attributes, and all other schema rules +still receive full XSD validation. Regression tests cover both representations, +multiple true flags, and false flags alongside unrelated schema errors. + +This schema checks representation, not identity, ownership, or file safety. +Callers still have to reconcile both embedded documents against the canonical +snapshot and obey the archive mutation/ownership contract before writing. diff --git a/src/pullbox/core/metroninfo_schema.py b/src/pullbox/core/metroninfo_schema.py new file mode 100644 index 00000000..b9c3b75a --- /dev/null +++ b/src/pullbox/core/metroninfo_schema.py @@ -0,0 +1,45 @@ +"""Offline MetronInfo 1.1 validation before paired archive metadata writes.""" + +from functools import lru_cache +from importlib.resources import files + +from xmlschema import XMLResource, XMLSchema11 + +from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml + + +@lru_cache(maxsize=1) +def _schema() -> XMLSchema11: + payload = files("pullbox").joinpath("core/metadata_schemas/MetronInfo-1.1.xsd").read_bytes() + # The pinned schema bytes retain namespace declarations used by QName attributes. + return XMLSchema11( + payload, + allow="none", + defuse="always", + use_fallback=False, + use_cache=False, + use_location_hints=False, + ) + + +def validate_metroninfo_xml(payload: bytes | str) -> None: + """Reject unsafe or schema-invalid output without exposing its contents.""" + root = parse_metadata_xml(payload, root_name="MetronInfo") + # xmlschema 4.3's assertion context treats explicit false attributes as true. + # In this pinned XSD, an absent optional primary flag is equivalent to false. + # Normalize only valid false lexemes on this private tree; never alter the XML + # payload, schema, true flags, other attributes, or other validation rules. + for path in ("IDS/ID", "URLs/URL"): + for node in root.findall(path): + flag = node.get("primary") + if flag is None: + continue + normalized = flag.strip(" \t\r\n") + if normalized not in {"false", "0", "true", "1"}: + raise MetadataXmlError("invalid_schema") + if normalized in {"false", "0"}: + del node.attrib["primary"] + # Only a bounded, defused tree reaches the validator, never a caller-provided URI. + resource = XMLResource(root, allow="none", defuse="always") + if not _schema().is_valid(resource, use_defaults=False, use_location_hints=False): + raise MetadataXmlError("invalid_schema") diff --git a/tests/unit/test_metroninfo_schema.py b/tests/unit/test_metroninfo_schema.py new file mode 100644 index 00000000..b675d336 --- /dev/null +++ b/tests/unit/test_metroninfo_schema.py @@ -0,0 +1,215 @@ +"""The write gate enforces the complete pinned XSD 1.1, entirely offline.""" + +import hashlib +import socket +from concurrent.futures import ThreadPoolExecutor +from importlib.resources import files +from unittest.mock import Mock + +import pytest + +from pullbox.core.metadata_xml import MAX_METADATA_XML_BYTES, MetadataXmlError +from pullbox.core.metroninfo_schema import validate_metroninfo_xml + + +def document(content: str = "", *, series: str = "Gen13") -> str: + return f"{series}{content}" + + +@pytest.mark.parametrize( + "content", + [ + "", + "50-x", + "13b0", + "2024-02-292024-01-31", + '123' + '456789', + 'https://metron.cloud/issue/456/' + 'https://comicvine.gamespot.com/4000-123/', + 'DCVertigo', + 'Event1', + 'A Writer' + 'WriterCover', + '3.99Teen Plus', + 'Earth' + "Prime", + "97812345678972026-09-28T00:00:00Z", + ], +) +def test_supported_metroninfo_11_is_valid(content: str) -> None: + assert validate_metroninfo_xml(document(content)) is None + + +@pytest.mark.parametrize( + "content", + [ + "Unsupported extension", + "-1", + "one", + "23", + "2023-02-29", + "2026-09", + "Maybe", + '1', + "1", + '1', + '3.99', + "Vertigo", + "Event0", + "Writer", + "NameInvalid" + "", + "6", + ], +) +def test_schema_invalid_content_is_not_accepted(content: str) -> None: + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document(content)) + + +@pytest.mark.parametrize( + "series", + [ + "", + "Gen130", + "Gen13-1", + "Gen13v2", + "Gen13Unknown format", + "Gen1320", + ], +) +def test_series_schema_constraints_are_enforced(series: str) -> None: + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document(series=series)) + + +@pytest.mark.parametrize("primary", ["true", "1"]) +@pytest.mark.parametrize("container", ["IDS", "URLs"]) +def test_xsd_11_assertions_reject_multiple_primary_items(primary: str, container: str) -> None: + tag = 'ID source="Metron"' if container == "IDS" else "URL" + close = "ID" if container == "IDS" else "URL" + content = f'<{container}><{tag} primary="{primary}">1' + content += f'<{tag} primary="{primary}">2' + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document(content)) + + +@pytest.mark.parametrize("false", ["false", "0", "\t false \n"]) +@pytest.mark.parametrize("true", ["true", "1"]) +@pytest.mark.parametrize("container", ["IDS", "URLs"]) +def test_explicit_false_primary_is_equivalent_to_absence( + false: str, + true: str, + container: str, +) -> None: + tag = 'ID source="Metron"' if container == "IDS" else "URL" + close = "ID" if container == "IDS" else "URL" + content = f'<{container}><{tag} primary="{true}">1' + content += f'<{tag} primary="{false}">2' * 2 + f"" + assert validate_metroninfo_xml(document(content)) is None + conflicting = content.replace(f'primary="{false}"', f'primary="{true}"', 1) + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document(conflicting)) + + +@pytest.mark.parametrize("false", ["FALSE", "no", "\u00a0false\u00a0"]) +def test_false_normalization_does_not_accept_invalid_boolean_lexemes(false: str) -> None: + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document(f'x')) + + +def test_false_normalization_does_not_relax_other_schema_rules() -> None: + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document('1')) + with pytest.raises(MetadataXmlError, match=r"^invalid_schema$"): + validate_metroninfo_xml(document('X')) + + +@pytest.mark.parametrize( + ("payload", "code"), + [ + ("", "invalid_xml"), + ("", "invalid_root"), + ("", "invalid_schema"), + ('', "invalid_root"), + (']>&x;', "unsafe_xml"), + ( + document( + '' + ), + "unsafe_xml", + ), + ("" + "x" * MAX_METADATA_XML_BYTES + "", "too_large"), + (document("" + "x" * 4096 + ""), "complexity_limit"), + (document("" * 33 + "" * 33), "complexity_limit"), + ("file:///etc/passwd", "invalid_xml"), + ("https://example.invalid/metadata.xml", "invalid_xml"), + ], + ids=[ + "malformed", + "wrong-root", + "missing-series", + "namespace", + "entity", + "xinclude", + "byte-limit", + "node-limit", + "depth-limit", + "file-uri", + "remote-uri", + ], +) +def test_untrusted_input_is_bounded_before_schema_validation(payload: str, code: str) -> None: + with pytest.raises(MetadataXmlError, match=f"^{code}$"): + validate_metroninfo_xml(payload) + + +def test_errors_do_not_expose_document_values() -> None: + with pytest.raises(MetadataXmlError) as caught: + validate_metroninfo_xml(document("private-password")) + assert str(caught.value) == "invalid_schema" + assert caught.value.__cause__ is None + + +def test_schema_location_hints_never_trigger_network_or_local_file_access( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import xmlschema.resources.xml_resource as resources + + from pullbox.core.metroninfo_schema import _schema + + _schema.cache_clear() + access = Mock(side_effect=AssertionError("XML must not access resource URLs")) + monkeypatch.setattr(resources, "urlopen", access) + monkeypatch.setattr(socket, "create_connection", access) + for location in ("https://example.invalid/schema.xsd", "file:///etc/passwd"): + payload = document().replace( + "", + '', + ) + validate_metroninfo_xml(payload) + access.assert_not_called() + + +def test_parallel_validation_does_not_mix_documents() -> None: + def validate(index: int) -> bool: + try: + validate_metroninfo_xml(document(f"{index % 2 - 1}")) + except MetadataXmlError: + return False + return True + + with ThreadPoolExecutor(max_workers=8) as pool: + assert list(pool.map(validate, range(64))) == [bool(index % 2) for index in range(64)] + + +def test_upstream_schema_and_license_are_bundled() -> None: + resources = files("pullbox").joinpath("core/metadata_schemas") + schema = resources.joinpath("MetronInfo-1.1.xsd").read_text(encoding="utf-8") + assert hashlib.sha256(schema.encode("utf-8")).hexdigest() == ( + "c0af59fc39e17e32c1a01714edc42d920bddc9d14e880ffc55ab960b8243a092" + ) + assert ' Date: Mon, 28 Sep 2026 23:20:48 -0700 Subject: [PATCH 62/97] feat(metadata): render coordinated archive metadata safely --- docs/development/ARCHITECTURE_OVERVIEW.md | 19 +- src/pullbox/core/archive_metadata_fields.py | 26 + src/pullbox/core/metadata_xml.py | 19 +- .../services/archive_metadata_preservation.py | 250 ++++++ .../archive_metadata_reconciliation.py | 19 +- .../services/archive_metadata_rendering.py | 479 +++++++++++ tests/unit/test_archive_metadata_rendering.py | 772 ++++++++++++++++++ 7 files changed, 1572 insertions(+), 12 deletions(-) create mode 100644 src/pullbox/core/archive_metadata_fields.py create mode 100644 src/pullbox/services/archive_metadata_preservation.py create mode 100644 src/pullbox/services/archive_metadata_rendering.py create mode 100644 tests/unit/test_archive_metadata_rendering.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 73cea69d..2066be4c 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -491,7 +491,24 @@ a bounded, defused document with the bundled, pinned MetronInfo 1.1 XSD through cannot trigger network or filesystem access. Validation errors expose fixed codes, not XML payloads. The schema, MIT license and provenance ship in the Python package. This gate checks representation, not identity or write authority; -canonical paired rendering and archive integration are still required. +archive integration and ownership authorization are still required. + +`services/archive_metadata_rendering.py` renders both XML documents together from +independently bound canonical series/issue snapshots. It preserves supported +schema-specific metadata and refuses unassembled local changes, conflicting exact +IDs, unverified identity promotion, ambiguous primary sources, and resource-ID +reinterpretation. Explicit edits may replace descriptive values; provider refresh +requires an unchanged managed baseline. Shared credit comparisons project only +ComicInfo-supported roles while retaining richer Metron roles. Partial dates are +not expanded into invented days. Existing arc names/order are coordinated without +creating provider identities or canonical membership. Unknown extensions and +unrepresentable values stop for review instead of disappearing. Output passes the +offline XSD and a shared-value cross-check before either document is returned. +This pure renderer makes no provider/database calls or archive-file mutations; +the offline validator loads only its bundled schema resource. The single +archive rewrite/conversion path, canonical rich-field/membership expansion, +production consumer migration and managed-root authorization remain required; +legacy writers have not switched to this boundary yet. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/src/pullbox/core/archive_metadata_fields.py b/src/pullbox/core/archive_metadata_fields.py new file mode 100644 index 00000000..6ecff8b2 --- /dev/null +++ b/src/pullbox/core/archive_metadata_fields.py @@ -0,0 +1,26 @@ +"""The credit-role overlap between ComicInfo and richer canonical metadata.""" + +from pullbox.schemas.metadata_credits import MetadataCredit, parse_credits + +COMICINFO_ROLES = { + "Writer": "writer", + "Penciller": "penciller", + "Inker": "inker", + "Colorist": "colorist", + "Letterer": "letterer", + "CoverArtist": "cover", + "Editor": "editor", + "Translator": "translator", +} + + +def comicinfo_credits(credits: tuple[MetadataCredit, ...]) -> tuple[MetadataCredit, ...]: + """Project supported roles, without losing rich roles in the canonical record.""" + supported = set(COMICINFO_ROLES.values()) + return parse_credits( + tuple( + {"name": credit.name, "role": ", ".join(roles)} + for credit in credits + if (roles := [role for role in credit.role.split(", ") if role in supported]) + ) + ) diff --git a/src/pullbox/core/metadata_xml.py b/src/pullbox/core/metadata_xml.py index 8c761319..b3af0a11 100644 --- a/src/pullbox/core/metadata_xml.py +++ b/src/pullbox/core/metadata_xml.py @@ -25,7 +25,9 @@ def __init__(self, code: str) -> None: self.code = code -def parse_metadata_xml(payload: bytes | str, *, root_name: str) -> ElementTree.Element: +def parse_metadata_xml( + payload: bytes | str, *, root_name: str, preserve_misc: bool = False +) -> ElementTree.Element: if len(payload) > MAX_METADATA_XML_BYTES: raise MetadataXmlError("too_large") try: @@ -49,12 +51,25 @@ def parse_metadata_xml(payload: bytes | str, *, root_name: str) -> ElementTree.E root: ElementTree.Element | None = None iterator = DefusedElementTree.iterparse( stream, - events=("start", "end"), + events=("start", "end", "comment", "pi"), + parser=DefusedElementTree.DefusedXMLParser( + target=ElementTree.TreeBuilder( + insert_comments=preserve_misc, insert_pis=preserve_misc + ), + forbid_dtd=True, + forbid_entities=True, + forbid_external=True, + ), forbid_dtd=True, forbid_entities=True, forbid_external=True, ) for event, node in iterator: + if event in {"comment", "pi"}: + nodes += 1 + if nodes > MAX_METADATA_XML_NODES: + raise MetadataXmlError("complexity_limit") + continue if event == "start": if root is None: root = node diff --git a/src/pullbox/services/archive_metadata_preservation.py b/src/pullbox/services/archive_metadata_preservation.py new file mode 100644 index 00000000..53c2ee28 --- /dev/null +++ b/src/pullbox/services/archive_metadata_preservation.py @@ -0,0 +1,250 @@ +"""Preserve schema-specific values while coordinating shared archive metadata.""" + +from xml.etree import ElementTree as ET + +from pullbox.core.archive_metadata import ArchiveMetadataFiles +from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml +from pullbox.core.metroninfo_schema import validate_metroninfo_xml + +_SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation" +_CI_FIELDS = frozenset( + [ + "Title", + "Series", + "Number", + "Count", + "Volume", + "AlternateSeries", + "AlternateNumber", + "AlternateCount", + "Summary", + "Notes", + "Year", + "Month", + "Day", + "Writer", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Translator", + "Publisher", + "Imprint", + "Genre", + "Tags", + "Web", + "PageCount", + "LanguageISO", + "Format", + "BlackAndWhite", + "Manga", + "Characters", + "Teams", + "Locations", + "ScanInformation", + "StoryArc", + "StoryArcNumber", + "SeriesGroup", + "AgeRating", + "CommunityRating", + "MainCharacterOrTeam", + "Review", + "GTIN", + ] +) +_PAGE_ATTRS = frozenset( + ["Image", "Type", "DoublePage", "ImageSize", "Key", "Bookmark", "ImageWidth", "ImageHeight"] +) + + +class ArchiveMetadataRenderError(ValueError): + """Fixed error code and field locator, not untrusted metadata content.""" + + def __init__(self, code: str, field: str = "") -> None: + self.code = code + self.field = field + super().__init__(code) + + +def preserved_roots(files: ArchiveMetadataFiles) -> tuple[ET.Element, ET.Element]: + roots = [] + for member, name in ((files.comicinfo, "ComicInfo"), (files.metroninfo, "MetronInfo")): + if ( + member.entry_count > 1 + or member.diagnostics + or (member.entry_count and member.payload is None) + ): + raise ArchiveMetadataRenderError("invalid_input", name) + try: + root = ( + parse_metadata_xml(member.payload, root_name=name, preserve_misc=True) + if member.payload is not None + else ET.Element(name) + ) + if name == "ComicInfo": + _validate_comicinfo(root) + elif member.payload is not None: + if root.get("version") not in {None, "1.0", "1.1"}: + raise ArchiveMetadataRenderError("unsupported_content", name) + root.attrib.pop("version", None) + root.attrib.pop(_SCHEMA_HINT, None) + for node in root.findall("IDS/ID"): + source = node.get("source", "").strip().casefold() + label = { + "comicvine": "Comic Vine", + "comic vine": "Comic Vine", + "metron": "Metron", + "gcd": "Grand Comics Database", + "grand comics database": "Grand Comics Database", + "locg": "League of Comic Geeks", + "league of comic geeks": "League of Comic Geeks", + }.get(source) + if label is not None: + node.set("source", label) + for node in (*root.findall("GTIN/ISBN"), *root.findall("GTIN/UPC")): + if node.attrib or len(node): + raise ArchiveMetadataRenderError("unsupported_content", "GTIN") + validate_metroninfo_xml(ET.tostring(root, encoding="utf-8")) + except MetadataXmlError as exc: + raise ArchiveMetadataRenderError("invalid_input", name) from exc + roots.append(root) + return roots[0], roots[1] + + +def _validate_comicinfo(root: ET.Element) -> None: + if set(root.attrib) - {_SCHEMA_HINT} or (root.text or "").strip(): + raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo") + seen = set() + for node in root: + if not isinstance(node.tag, str): + continue + if node.tag in seen or (node.tail or "").strip(): + raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo") + seen.add(node.tag) + if node.tag == "Pages": + if node.attrib or (node.text or "").strip(): + raise ArchiveMetadataRenderError("unsupported_content", "Pages") + for page in node: + if not isinstance(page.tag, str): + continue + if ( + page.tag != "Page" + or set(page.attrib) - _PAGE_ATTRS + or len(page) + or (page.text or "").strip() + or (page.tail or "").strip() + ): + raise ArchiveMetadataRenderError("unsupported_content", "Pages") + elif node.tag not in _CI_FIELDS or node.attrib or len(node): + raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo") + + +def set_text(root: ET.Element, path: str, value: object) -> None: + node = root.find(path) + if node is not None and "id" in node.attrib and node.text != str(value): + raise ArchiveMetadataRenderError("resource_metadata_change", path) + if value is None or value == "": + if node is not None: + if node.attrib or len(node): + raise ArchiveMetadataRenderError("unsupported_content", path) + root.remove(node) + return + if node is None: + node = ET.SubElement(root, path) + node.text = str(value) + + +def _parts(value: str | None) -> tuple[str, ...]: + return tuple(part.strip() for part in value.split(",") if part.strip()) if value else () + + +def coordinate_preserved_fields(ci: ET.Element, mi: ET.Element) -> None: + """Build shared values from agreeing existing evidence, never attach identities.""" + for ci_tag, mi_path in ( + ("Genre", "Genres/Genre"), + ("Tags", "Tags/Tag"), + ("Characters", "Characters/Character"), + ("Teams", "Teams/Team"), + ("Locations", "Locations/Location"), + ): + left = _parts(ci.findtext(ci_tag)) + right = tuple((node.text or "").strip() for node in mi.findall(mi_path)) + if any(not value or "," in value for value in right): + raise ArchiveMetadataRenderError("unsupported_content", ci_tag) + if left and right and left != right: + raise ArchiveMetadataRenderError("unreconciled_field", ci_tag) + values = right or left + if values: + set_text(ci, ci_tag, ", ".join(values)) + if not right: + parent_tag, child_tag = mi_path.split("/") + parent = mi.find(parent_tag) + if parent is None: + parent = ET.SubElement(mi, parent_tag) + for value in values: + ET.SubElement(parent, child_tag).text = value + for ci_tag, mi_path in (("Imprint", "Publisher/Imprint"), ("AgeRating", "AgeRating")): + left_text, right_text = ci.findtext(ci_tag), mi.findtext(mi_path) + if left_text and right_text and left_text != right_text: + raise ArchiveMetadataRenderError("unreconciled_field", ci_tag) + scalar = right_text or left_text + if scalar: + set_text(ci, ci_tag, scalar) + if not right_text: + if "/" in mi_path: + parent_tag, child_tag = mi_path.split("/") + parent = mi.find(parent_tag) + if parent is None: + raise ArchiveMetadataRenderError("unreconciled_field", ci_tag) + set_text(parent, child_tag, scalar) + else: + set_text(mi, mi_path, scalar) + _arcs(ci, mi) + + +def _arcs(ci: ET.Element, mi: ET.Element) -> None: + names = _parts(ci.findtext("StoryArc")) + numbers = _parts(ci.findtext("StoryArcNumber")) + if numbers and ( + len(numbers) != len(names) + or any(len(n) > 10 or not n.isascii() or not n.isdecimal() or int(n) < 1 for n in numbers) + ): + raise ArchiveMetadataRenderError("unsupported_content", "StoryArcNumber") + arcs = mi.findall("Arcs/Arc") + right_names = tuple((arc.findtext("Name") or "").strip() for arc in arcs) + right_numbers = tuple(arc.findtext("Number") for arc in arcs) + if any(not name or "," in name for name in right_names): + raise ArchiveMetadataRenderError("unsupported_content", "StoryArc") + if names and right_names and names != right_names: + raise ArchiveMetadataRenderError("unreconciled_field", "StoryArc") + if ( + numbers + and right_numbers + and any( + right is not None and int(left) != int(right) + for left, right in zip(numbers, right_numbers, strict=True) + ) + ): + raise ArchiveMetadataRenderError("unreconciled_field", "StoryArcNumber") + if right_names: + set_text(ci, "StoryArc", ", ".join(right_names)) + if numbers: + for arc, number in zip(arcs, numbers, strict=True): + set_text(arc, "Number", int(number)) + elif all(right_numbers): + set_text( + ci, + "StoryArcNumber", + ", ".join(str(int(number)) for number in right_numbers if number is not None), + ) + elif names: + parent = mi.find("Arcs") + if parent is None: + parent = ET.SubElement(mi, "Arcs") + for index, name in enumerate(names): + arc = ET.SubElement(parent, "Arc") + ET.SubElement(arc, "Name").text = name + if numbers: + ET.SubElement(arc, "Number").text = str(int(numbers[index])) diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py index 78e61ca8..c2ac69b6 100644 --- a/src/pullbox/services/archive_metadata_reconciliation.py +++ b/src/pullbox/services/archive_metadata_reconciliation.py @@ -9,6 +9,7 @@ from pydantic import ValidationError from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.archive_metadata_fields import COMICINFO_ROLES, comicinfo_credits from pullbox.core.comicinfo_sanitizer import references_stale_retailer from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.metadata_identity import ( @@ -24,15 +25,7 @@ from pullbox.core.metroninfo import MetronInfoData, parse_metroninfo from pullbox.schemas.metadata_snapshot import MetadataValues -_CI_ROLES = { - "Writer": "writer", - "Penciller": "penciller", - "Inker": "inker", - "Colorist": "colorist", - "Letterer": "letterer", - "CoverArtist": "cover", - "Editor": "editor", -} +_CI_ROLES = COMICINFO_ROLES _CV_HOSTS = frozenset( {"comicvine.gamespot.com", "www.comicvine.gamespot.com", "comicvine.com", "www.comicvine.com"} ) @@ -128,6 +121,14 @@ def _shared_values( values: dict[str, object] = {} for name in MetadataValues.model_fields: left, right = getattr(comic, name), getattr(metron, name) + if ( + name == "credits" + and comic.credits is not None + and metron.credits is not None + and comic.credits == comicinfo_credits(metron.credits) + ): + values[name] = metron.credits + continue if left is not None and right is not None and left != right: differences.append(ArchiveMetadataDifference(entity, name, left, right)) else: diff --git a/src/pullbox/services/archive_metadata_rendering.py b/src/pullbox/services/archive_metadata_rendering.py new file mode 100644 index 00000000..3f7243df --- /dev/null +++ b/src/pullbox/services/archive_metadata_rendering.py @@ -0,0 +1,479 @@ +"""Render coordinated metadata without authorizing filesystem or identity changes.""" + +from dataclasses import dataclass +from xml.etree import ElementTree as ET + +from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.archive_metadata_fields import COMICINFO_ROLES, comicinfo_credits +from pullbox.core.issue_numbers import normalize_issue_number_text +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml +from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.services.archive_metadata_preservation import ( + ArchiveMetadataRenderError as ArchiveMetadataRenderError, +) +from pullbox.services.archive_metadata_preservation import ( + coordinate_preserved_fields, + preserved_roots, + set_text, +) +from pullbox.services.archive_metadata_reconciliation import ( + ArchiveMetadataReconciliation, + reconcile_archive_metadata, +) + +_SOURCES = { + IdentityNamespace.COMICVINE: "Comic Vine", + IdentityNamespace.METRON: "Metron", + IdentityNamespace.GCD: "Grand Comics Database", +} +_FORMATS = { + "standard": "Single Issue", + "tpb": "Trade Paperback", + "one_shot": "One-Shot", + "annual": "Annual", + "hardcover": "Hardcover", + "omnibus": "Omnibus", + "graphic_novel": "Graphic Novel", +} +_COMICINFO_ONLY_FORMATS = frozenset({"special", "compendium", "deluxe", "volume"}) + + +def _format_text(value: str | None) -> str | None: + if value in _COMICINFO_ONLY_FORMATS: + return value.title() + return _FORMATS.get(value, value) if value is not None else None + + +_SERIES_PATHS = { + "title": ("Series", "Name"), + "sort_title": (None, "SortName"), + "year_start": (None, "StartYear"), + "volume": (None, "Volume"), + "series_type": ("Format", "Format"), + "issue_count": ("Count", "IssueCount"), +} +_ISSUE_PATHS = { + "issue_number_text": ("Number", "Number"), + "description": ("Summary", "Summary"), + "page_count": ("PageCount", "PageCount"), + "store_date": (None, "StoreDate"), +} + + +@dataclass(frozen=True) +class RenderedArchiveMetadata: + comicinfo: bytes + metroninfo: bytes + + +def render_archive_metadata( + series: MetadataSnapshot, + issue: MetadataSnapshot, + files: ArchiveMetadataFiles, + *, + primary_identity: ExternalIdentityRef | None = None, + previous_series: MetadataSnapshot | None = None, + previous_issue: MetadataSnapshot | None = None, + replace_managed: bool = False, +) -> RenderedArchiveMetadata: + """Render both documents from bound snapshots; this is not write permission. + + The caller must independently bind the issue and its parent, assemble local + metadata into these snapshots, and authorize the eventual file operation. + Previous managed baselines authorize refresh only while XML is unchanged. + """ + if ( + series.entity_kind is not MetadataEntityKind.SERIES + or issue.entity_kind is not MetadataEntityKind.ISSUE + ): + raise ArchiveMetadataRenderError("invalid_snapshot") + for current, previous in ((series, previous_series), (issue, previous_issue)): + if previous is not None and ( + previous.entity_kind != current.entity_kind + or set(previous.identities) != set(current.identities) + ): + raise ArchiveMetadataRenderError("invalid_baseline") + archive = reconcile_archive_metadata(files) + _identities(series, issue, archive) + if any(item.code != "unmapped_content" for item in archive.diagnostics): + raise ArchiveMetadataRenderError("invalid_input") + ci, mi = preserved_roots(files) + primary = _primary(issue, archive, primary_identity) + _namespace_guard(mi, archive, primary) + _guard_values(series, issue, archive, previous_series, previous_issue, replace_managed) + for field, tag in (("series_type", "Format"), ("issue_count", "Count")): + text = ci.findtext(tag) + old_value: str | int | None = text + if text and field == "issue_count": + if len(text) > 10 or not text.isascii() or not text.isdecimal(): + raise ArchiveMetadataRenderError("invalid_input", tag) + old_value = int(text) + comparison = getattr(series.values, field) + if field == "series_type": + old_value, comparison = _format_text(text), _format_text(series.values.series_type) + if ( + text + and old_value != comparison + and not _authorized(series, previous_series, field, old_value, replace_managed) + ): + raise ArchiveMetadataRenderError("unreconciled_field", field) + _render_core(series, issue, ci, mi) + _render_ids(series, issue, ci, mi, primary) + _render_credits(issue, archive, ci, mi) + coordinate_preserved_fields(ci, mi) + return _validated_pair(ci, mi) + + +def _identities( + series: MetadataSnapshot, issue: MetadataSnapshot, archive: ArchiveMetadataReconciliation +) -> None: + verified = {identity for snapshot in (series, issue) for identity in snapshot.identities} + if archive.identity_conflicts: + raise ArchiveMetadataRenderError("identity_conflict") + for item in archive.evidence: + identity = item.identity + if any( + (identity.namespace, identity.entity_kind) == (target.namespace, target.entity_kind) + and identity != target + for target in verified + ): + raise ArchiveMetadataRenderError("identity_conflict") + if identity not in verified: + raise ArchiveMetadataRenderError("unverified_identity") + + +def _primary( + issue: MetadataSnapshot, + archive: ArchiveMetadataReconciliation, + requested: ExternalIdentityRef | None, +) -> ExternalIdentityRef | None: + if any(identity.namespace not in _SOURCES for identity in issue.identities): + raise ArchiveMetadataRenderError("unsupported_identity") + if requested is not None: + if requested not in issue.identities: + raise ArchiveMetadataRenderError("invalid_primary_identity") + return requested + data = archive.metroninfo.metron + if data is not None: + for item in data.identities: + if item.primary and item.evidence.identity in issue.identities: + return item.evidence.identity + if len(issue.identities) > 1: + raise ArchiveMetadataRenderError("primary_identity_required") + return next(iter(issue.identities), None) + + +def _namespace_guard( + mi: ET.Element, archive: ArchiveMetadataReconciliation, primary: ExternalIdentityRef | None +) -> None: + old = archive.metroninfo.metron + namespace = primary.namespace if primary is not None else (old.primary_source if old else None) + if ( + old is not None + and old.primary_source != namespace + and any("id" in node.attrib for node in mi.iter() if node.tag != "Series") + ): + raise ArchiveMetadataRenderError("resource_namespace_change") + + +def _authorized( + snapshot: MetadataSnapshot, + previous: MetadataSnapshot | None, + field: str, + existing: object, + replace_managed: bool, + *, + project_credits: bool = False, +) -> bool: + if any(origin.field == field and origin.user_override for origin in snapshot.origins): + return True + baseline = getattr(previous.values, field) if previous is not None else None + if field == "series_type" and previous is not None: + baseline = _format_text(previous.values.series_type) + if project_credits and previous is not None and previous.values.credits is not None: + baseline = comicinfo_credits(previous.values.credits) + return bool( + replace_managed + and previous is not None + and baseline == existing + and any(origin.field == field and origin.source is not None for origin in previous.origins) + and any(origin.field == field and origin.source is not None for origin in snapshot.origins) + ) + + +def _guard_values( + series: MetadataSnapshot, + issue: MetadataSnapshot, + archive: ArchiveMetadataReconciliation, + previous_series: MetadataSnapshot | None, + previous_issue: MetadataSnapshot | None, + replace_managed: bool, +) -> None: + for document, values in (("ComicInfo", archive.comicinfo), ("MetronInfo", archive.metroninfo)): + for snapshot, existing, previous in ( + (series, values.series, previous_series), + (issue, values.issue, previous_issue), + ): + for field in MetadataValues.model_fields: + old, new = getattr(existing, field), getattr(snapshot.values, field) + if old is None: + continue + if field == "issue_number_text": + try: + equal = new is not None and normalize_issue_number_text( + old + ) == normalize_issue_number_text(new) + except ValueError: + equal = False + if not equal: + raise ArchiveMetadataRenderError("issue_number_conflict") + continue + if ( + field == "credits" + and document == "ComicInfo" + and snapshot.values.credits is not None + ): + new = comicinfo_credits(snapshot.values.credits) + if field == "series_type": + old, new = ( + _format_text(existing.series_type), + _format_text(snapshot.values.series_type), + ) + if old != new and not _authorized( + snapshot, + previous, + field, + old, + replace_managed, + project_credits=field == "credits" and document == "ComicInfo", + ): + raise ArchiveMetadataRenderError("unreconciled_field", field) + parts = archive.comicinfo.publication_date_parts + current = issue.values.cover_date + if ( + current is None + and archive.comicinfo.issue.cover_date is None + and not any( + origin.field == "cover_date" and origin.user_override for origin in issue.origins + ) + ): + return + if any(part is not None for part in parts): + new_parts = ( + (current.year, current.month, current.day) + if current is not None + else (None, None, None) + ) + if any(old is not None and old != new for old, new in zip(parts, new_parts, strict=True)): + previous_date = previous_issue.values.cover_date if previous_issue is not None else None + old_parts = ( + (previous_date.year, previous_date.month, previous_date.day) + if previous_date is not None + else (None, None, None) + ) + if not _authorized( + issue, previous_issue, "cover_date", previous_date, replace_managed + ) or not ( + any( + origin.field == "cover_date" and origin.user_override + for origin in issue.origins + ) + or all( + part is None or part == old for part, old in zip(parts, old_parts, strict=True) + ) + ): + raise ArchiveMetadataRenderError("unreconciled_field", "cover_date") + + +def _render_core( + series: MetadataSnapshot, issue: MetadataSnapshot, ci: ET.Element, mi: ET.Element +) -> None: + if not series.values.title or not series.values.title.strip(): + raise ArchiveMetadataRenderError("missing_series_title") + parent = mi.find("Series") + if parent is None: + parent = ET.SubElement(mi, "Series") + for field, (ci_tag, mi_tag) in _SERIES_PATHS.items(): + value = getattr(series.values, field) + if field == "series_type": + value = _format_text(series.values.series_type) + if ci_tag is not None: + set_text(ci, ci_tag, value) + if field == "series_type" and series.values.series_type in _COMICINFO_ONLY_FORMATS: + value = None + if field == "year_start" and series.values.year_start is not None: + value = f"{series.values.year_start:04d}" + set_text(parent, mi_tag, None if field == "issue_count" and value == 0 else value) + language = series.values.language + set_text(ci, "LanguageISO", language) + if language: + parent.set("lang", language) + else: + parent.attrib.pop("lang", None) + publisher = mi.find("Publisher") + if ( + publisher is not None + and "id" in publisher.attrib + and publisher.findtext("Name") != series.values.publisher + ): + raise ArchiveMetadataRenderError("resource_metadata_change", "publisher") + if series.values.publisher: + if publisher is None: + publisher = ET.SubElement(mi, "Publisher") + set_text(publisher, "Name", series.values.publisher) + elif publisher is not None: + if publisher.attrib or publisher.find("Imprint") is not None: + raise ArchiveMetadataRenderError("unsupported_content", "Publisher") + mi.remove(publisher) + set_text(ci, "Publisher", series.values.publisher) + for field, (ci_tag, mi_tag) in _ISSUE_PATHS.items(): + value = getattr(issue.values, field) + if ci_tag is not None: + set_text(ci, ci_tag, value) + set_text(mi, mi_tag, value) + cover = issue.values.cover_date + set_text(mi, "CoverDate", cover) + if ( + cover is not None + or all(ci.findtext(tag) for tag in ("Year", "Month", "Day")) + or any(origin.field == "cover_date" and origin.user_override for origin in issue.origins) + ): + for tag, value in zip( + ("Year", "Month", "Day"), + (cover.year, cover.month, cover.day) if cover else (None, None, None), + strict=True, + ): + set_text(ci, tag, value) + title = issue.values.title + stories = mi.find("Stories") + if stories is not None and len(stories.findall("Story")) > 1: + if title: + raise ArchiveMetadataRenderError("unreconciled_field", "title") + elif title: + if stories is None: + stories = ET.SubElement(mi, "Stories") + set_text(stories, "Story", title) + elif stories is not None: + _assert_container_replaceable(stories, "title") + mi.remove(stories) + set_text(ci, "Title", title) + + +def _render_ids( + series: MetadataSnapshot, + issue: MetadataSnapshot, + ci: ET.Element, + mi: ET.Element, + primary: ExternalIdentityRef | None, +) -> None: + ids = mi.find("IDS") + if ids is None and issue.identities: + ids = ET.SubElement(mi, "IDS") + if ids is not None: + for child in ids: + if child.get("source", "").casefold() in {"league of comic geeks", "locg"}: + child.set("source", "League of Comic Geeks") + if primary is not None: + child.attrib.pop("primary", None) + for identity in sorted( + issue.identities, key=lambda item: (item.namespace, item.external_id) + ): + nodes = [ + node + for node in ids.findall("ID") + if node.get("source") == _SOURCES[identity.namespace] + ] + if not nodes: + nodes = [ET.SubElement(ids, "ID", {"source": _SOURCES[identity.namespace]})] + for child in nodes: + child.text = identity.external_id + child.attrib.pop("primary", None) + if identity == primary: + nodes[0].set("primary", "true") + parent = mi.find("Series") + assert parent is not None + if primary is not None: + parent.attrib.pop("id", None) + for identity in series.identities: + if identity.namespace == primary.namespace: + parent.set("id", identity.external_id) + notes = ci.findtext("Notes") or "" + for snapshot, marker in ((series, "cv_vol_id"), (issue, "cv_issue_id")): + for identity in snapshot.identities: + if identity.namespace is IdentityNamespace.COMICVINE: + token = f"[{marker}:{identity.external_id}]" + if token not in notes: + notes = f"{notes} {token}".strip() + if notes: + set_text(ci, "Notes", notes) + + +def _render_credits( + issue: MetadataSnapshot, archive: ArchiveMetadataReconciliation, ci: ET.Element, mi: ET.Element +) -> None: + credits = issue.values.credits + if credits is None: + return + if any( + "," in credit.name + for credit in credits + if any(role in COMICINFO_ROLES.values() for role in credit.role.split(", ")) + ): + raise ArchiveMetadataRenderError("unsupported_content", "credits") + for tag, role in COMICINFO_ROLES.items(): + names = [credit.name for credit in credits if role in credit.role.split(", ")] + set_text(ci, tag, ", ".join(names)) + if not comicinfo_credits(credits): + ET.SubElement(ci, "Writer") + if archive.metroninfo.issue.credits == credits: + return + existing = mi.find("Credits") + if existing is not None: + _assert_container_replaceable(existing, "credits") + mi.remove(existing) + parent = ET.SubElement(mi, "Credits") + for credit in credits: + node = ET.SubElement(parent, "Credit") + ET.SubElement(node, "Creator").text = credit.name + if credit.role: + roles = ET.SubElement(node, "Roles") + for role in credit.role.split(", "): + ET.SubElement(roles, "Role").text = role.title() + + +def _assert_container_replaceable(node: ET.Element, field: str) -> None: + if any("id" in child.attrib for child in node.iter()): + raise ArchiveMetadataRenderError("resource_metadata_change", field) + if any(not isinstance(child.tag, str) for child in node.iter()): + raise ArchiveMetadataRenderError("unsupported_content", field) + + +def _validated_pair(ci: ET.Element, mi: ET.Element) -> RenderedArchiveMetadata: + result = RenderedArchiveMetadata( + ET.tostring(ci, encoding="utf-8", xml_declaration=True), + ET.tostring(mi, encoding="utf-8", xml_declaration=True), + ) + try: + parse_metadata_xml(result.comicinfo, root_name="ComicInfo") + validate_metroninfo_xml(result.metroninfo) + except MetadataXmlError as exc: + raise ArchiveMetadataRenderError("invalid_output") from exc + check = reconcile_archive_metadata( + ArchiveMetadataFiles( + MetadataFile("ComicInfo.xml", 1, result.comicinfo), + MetadataFile("MetronInfo.xml", 1, result.metroninfo), + ) + ) + if ( + check.differences + or check.identity_conflicts + or any(item.code != "unmapped_content" for item in check.diagnostics) + ): + raise ArchiveMetadataRenderError("output_disagreement") + return result diff --git a/tests/unit/test_archive_metadata_rendering.py b/tests/unit/test_archive_metadata_rendering.py new file mode 100644 index 00000000..d222fe77 --- /dev/null +++ b/tests/unit/test_archive_metadata_rendering.py @@ -0,0 +1,772 @@ +"""Paired output must preserve evidence, edits, and supported archive metadata.""" + +from datetime import UTC, date, datetime +from xml.etree import ElementTree as ET + +import pytest + +from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + MetadataSource, +) +from pullbox.core.metadata_identity import ( + IdentityNamespace as Namespace, +) +from pullbox.core.metadata_identity import ( + MetadataEntityKind as Kind, +) +from pullbox.core.metroninfo import parse_metroninfo +from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import MetadataDomain +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.archive_metadata_rendering import ( + ArchiveMetadataRenderError, + render_archive_metadata, +) + +CV_SERIES = ExternalIdentityRef(Namespace.COMICVINE, Kind.SERIES, "42") +CV_ISSUE = ExternalIdentityRef(Namespace.COMICVINE, Kind.ISSUE, "7") +METRON_SERIES = ExternalIdentityRef(Namespace.METRON, Kind.SERIES, "88") +METRON_ISSUE = ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, "9") + + +def snapshots(**issue_values): + return ( + MetadataSnapshot( + entity_kind=Kind.SERIES, + identities=(CV_SERIES,), + values=MetadataValues(title="Example & Friends", publisher="Publisher"), + ), + MetadataSnapshot( + entity_kind=Kind.ISSUE, + identities=(CV_ISSUE,), + values=MetadataValues(issue_number_text="50-x", **issue_values), + ), + ) + + +def files(ci="", mi=""): + return ArchiveMetadataFiles( + MetadataFile("ComicInfo.xml", int(bool(ci)), ci.encode() if ci else None), + MetadataFile("MetronInfo.xml", int(bool(mi)), mi.encode() if mi else None), + ) + + +def metron(content="", series="Example & Friends", attrs=""): + return f"{series}{content}" + + +def origin(field, *, user=False): + return FieldOrigin( + field=field, + domain=MetadataDomain.CORE, + observed_at=datetime(2026, 9, 28, tzinfo=UTC), + source=None if user else MetadataSource.COMICVINE_API, + user_override=user, + ) + + +def test_paired_core_output_uses_one_snapshot_and_validates_offline(): + series, issue = snapshots( + title="Part ", + description="A & B", + page_count=0, + cover_date=date(2026, 9, 28), + store_date=date(2026, 9, 25), + credits=({"name": "A Writer", "role": "writer, penciller"},), + ) + result = render_archive_metadata(series, issue, files()) + assert result.comicinfo and result.metroninfo + ci = ET.fromstring(result.comicinfo) + mi = parse_metroninfo(result.metroninfo) + validate_metroninfo_xml(result.metroninfo) + assert ci.findtext("Series") == mi.series == series.values.title + assert ci.findtext("Title") == mi.stories[0] == issue.values.title + assert ci.findtext("Summary") == mi.summary == issue.values.description + assert ci.findtext("Number") == mi.number == "50-x" + assert ci.findtext("Publisher") == mi.publisher == "Publisher" + assert ci.findtext("PageCount") == "0" and mi.page_count == 0 + assert (ci.findtext("Year"), ci.findtext("Month"), ci.findtext("Day")) == ("2026", "9", "28") + assert mi.cover_date == issue.values.cover_date and mi.store_date == issue.values.store_date + assert ci.findtext("Writer") == ci.findtext("Penciller") == "A Writer" + assert {r.value for r in mi.credits[0].roles} == {"Writer", "Penciller"} + assert {e.identity for e in mi.evidence} == {CV_SERIES, CV_ISSUE} + assert "[cv_issue_id:7]" in ci.findtext("Notes") + assert "[cv_vol_id:42]" in ci.findtext("Notes") + + +def test_multiple_verified_ids_require_explicit_or_existing_primary(): + series, issue = snapshots() + series = series.model_copy(update={"identities": (CV_SERIES, METRON_SERIES)}) + issue = issue.model_copy(update={"identities": (CV_ISSUE, METRON_ISSUE)}) + with pytest.raises(ArchiveMetadataRenderError, match="primary_identity_required"): + render_archive_metadata(series, issue, files()) + rendered = render_archive_metadata(series, issue, files(), primary_identity=METRON_ISSUE) + mi = parse_metroninfo(rendered.metroninfo) + assert mi.primary_source == Namespace.METRON + assert {e.identity for e in mi.evidence} == {METRON_SERIES, CV_ISSUE, METRON_ISSUE} + assert ET.fromstring(rendered.metroninfo).find("Series").get("id") == "88" + assert ( + render_archive_metadata(series, issue, files(mi=rendered.metroninfo.decode())) == rendered + ) + + +@pytest.mark.parametrize("primary", [CV_SERIES, METRON_ISSUE]) +def test_primary_identity_must_be_a_verified_issue(primary): + with pytest.raises(ArchiveMetadataRenderError, match="invalid_primary_identity"): + render_archive_metadata(*snapshots(), files(), primary_identity=primary) + + +@pytest.mark.parametrize( + "ci,mi", + [ + ("[cv_issue_id:8]", ""), + ("[cv_vol_id:43]", ""), + ("", metron('8')), + ("", metron('7', attrs='id="43"')), + ], +) +def test_conflicting_exact_ids_stop_even_explicit_override(ci, mi): + series, issue = snapshots(title="New") + issue = issue.model_copy(update={"origins": (origin("title", user=True),)}) + with pytest.raises(ArchiveMetadataRenderError, match="identity_conflict"): + render_archive_metadata(series, issue, files(ci, mi), replace_managed=True) + + +def test_unverified_archive_id_is_not_promoted_or_discarded(): + incoming = files(mi=metron('9')) + with pytest.raises(ArchiveMetadataRenderError, match="unverified_identity"): + render_archive_metadata(*snapshots(), incoming) + + +@pytest.mark.parametrize("old,new", [("Local title", "Provider title"), ("Local title", None)]) +def test_unassembled_local_values_cannot_be_overwritten_or_erased(old, new): + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata( + *snapshots(title=new), files(ci=f"{old}") + ) + + +@pytest.mark.parametrize("value", [None, "", "Reviewed title"]) +def test_explicit_user_override_updates_or_clears_both_documents(value): + series, issue = snapshots(title=value) + issue = issue.model_copy(update={"origins": (origin("title", user=True),)}) + result = render_archive_metadata( + series, + issue, + files( + ci="Local", + mi=metron("Other local"), + ), + ) + assert result.comicinfo and result.metroninfo + assert ET.fromstring(result.comicinfo).findtext("Title") == (value or None) + assert parse_metroninfo(result.metroninfo).stories == ((value,) if value else ()) + + +@pytest.mark.parametrize( + "replace,edited,allowed", [(False, False, False), (True, True, False), (True, False, True)] +) +def test_managed_refresh_requires_unchanged_previous_baseline(replace, edited, allowed): + series, previous = snapshots(title="Old managed") + previous = previous.model_copy(update={"origins": (origin("title"),)}) + issue = previous.model_copy( + update={"values": previous.values.model_copy(update={"title": "New managed"})} + ) + incoming = files( + ci=f"{'Local edit' if edited else 'Old managed'}" + ) + if not allowed: + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata( + series, issue, incoming, previous_issue=previous, replace_managed=replace + ) + else: + result = render_archive_metadata( + series, issue, incoming, previous_issue=previous, replace_managed=True + ) + assert parse_metroninfo(result.metroninfo).stories == ("New managed",) + + +@pytest.mark.parametrize( + "ci,mi", + [ + ("50-o", ""), + ("50x", ""), + ("", metron("50-o")), + ], +) +def test_issue_designation_change_is_not_a_descriptive_override(ci, mi): + series, issue = snapshots() + issue = issue.model_copy(update={"origins": (origin("issue_number_text", user=True),)}) + with pytest.raises(ArchiveMetadataRenderError, match="issue_number_conflict"): + render_archive_metadata(series, issue, files(ci, mi)) + + +@pytest.mark.parametrize("number", ["50-X", "050-x"]) +def test_equivalent_lettered_issue_numbers_keep_canonical_text(number): + result = render_archive_metadata( + *snapshots(), files(ci=f"{number}") + ) + assert result.comicinfo and result.metroninfo + assert parse_metroninfo(result.metroninfo).number == "50-x" + + +@pytest.mark.parametrize( + "ci,mi", + [ + ("Keep me", ""), + ("Example", ""), + ("", metron("Keep me")), + ("broken", ""), + ("<!DOCTYPE ComicInfo [<!ENTITY x 'secret'>]><ComicInfo>&x;</ComicInfo>", ""), + ("", '<MetronInfo version="9"><Series><Name>Example</Name></Series></MetronInfo>'), + ], +) +def test_unsupported_or_unsafe_content_requires_review_not_loss(ci, mi): + with pytest.raises(ArchiveMetadataRenderError): + render_archive_metadata(*snapshots(), files(ci, mi)) + + +def test_preserves_pages_notes_comments_and_rich_supported_resources(): + incoming = files( + ci="<ComicInfo><!--reader note--><Notes>My notes</Notes><Pages>" + '<Page Image="0" Type="FrontCover" Bookmark="Start" /></Pages>' + "<Manga>No</Manga></ComicInfo>", + mi=metron( + '<IDS><ID source="Comic Vine" primary="true">7</ID></IDS>' + '<Characters><Character id="99">Hero</Character></Characters>' + '<Prices><Price country="US">3.99</Price></Prices>' + "<Notes>Metron note</Notes><GTIN><ISBN>123</ISBN></GTIN>" + ), + ) + result = render_archive_metadata(*snapshots(), incoming) + assert b"<!--reader note-->" in result.comicinfo + ci, mi = ET.fromstring(result.comicinfo), ET.fromstring(result.metroninfo) + assert ci.find("Pages/Page").attrib == {"Image": "0", "Type": "FrontCover", "Bookmark": "Start"} + assert ci.findtext("Manga") == "No" + assert ci.findtext("Notes").startswith("My notes") + assert mi.findtext("Notes") == "Metron note" + assert ci.findtext("Characters") == mi.findtext("Characters/Character") == "Hero" + assert mi.find("Characters/Character").get("id") == "99" + assert mi.findtext("Prices/Price") == "3.99" + assert mi.findtext("GTIN/ISBN") == "123" + assert incoming.comicinfo.payload.endswith(b"</ComicInfo>") + assert b"[cv_issue_id:7]" not in incoming.comicinfo.payload + + +def test_changing_primary_never_reinterprets_resource_ids(): + series, issue = snapshots() + series = series.model_copy(update={"identities": (CV_SERIES, METRON_SERIES)}) + issue = issue.model_copy(update={"identities": (CV_ISSUE, METRON_ISSUE)}) + incoming = files( + mi=metron( + '<IDS><ID source="Comic Vine" primary="true">7</ID></IDS>' + '<Characters><Character id="99">Hero</Character></Characters>' + ) + ) + with pytest.raises(ArchiveMetadataRenderError, match="resource_namespace_change"): + render_archive_metadata(series, issue, incoming, primary_identity=METRON_ISSUE) + + +@pytest.mark.parametrize( + "ci,mi", + [ + ("<ComicInfo><Year>2025</Year></ComicInfo>", ""), + ("<ComicInfo><Month>8</Month></ComicInfo>", ""), + ("<ComicInfo><Day>27</Day></ComicInfo>", ""), + ], +) +def test_partial_publication_date_cannot_be_replaced_by_unrelated_full_date(ci, mi): + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata(*snapshots(cover_date=date(2026, 9, 28)), files(ci, mi)) + + +def test_schema_failure_never_returns_half_a_pair(): + series, issue = snapshots() + series = series.model_copy( + update={"values": series.values.model_copy(update={"language": "english"})} + ) + with pytest.raises(ArchiveMetadataRenderError, match="invalid_output"): + render_archive_metadata(series, issue, files()) + + +def test_story_arc_names_and_order_are_coordinated_without_attaching_identity(): + incoming = files( + mi=metron( + "<Arcs><Arc><Name>First arc</Name><Number>2</Number></Arc>" + "<Arc><Name>Second arc</Name><Number>5</Number></Arc></Arcs>" + ) + ) + result = render_archive_metadata(*snapshots(), incoming) + assert result.comicinfo and result.metroninfo + ci = ET.fromstring(result.comicinfo) + assert ci.findtext("StoryArc") == "First arc, Second arc" + assert ci.findtext("StoryArcNumber") == "2, 5" + assert len(parse_metroninfo(result.metroninfo).arcs) == 2 + + +def test_conflicting_story_arc_membership_stops_for_review(): + incoming = files( + ci="<ComicInfo><StoryArc>Other</StoryArc></ComicInfo>", + mi=metron("<Arcs><Arc><Name>First arc</Name></Arc></Arcs>"), + ) + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata(*snapshots(), incoming) + + +def test_rich_roles_do_not_create_false_cross_document_credit_conflicts(): + series, issue = snapshots(credits=({"name": "Creator", "role": "writer, artist, translator"},)) + result = render_archive_metadata(series, issue, files()) + assert result.comicinfo and result.metroninfo + parsed = reconcile_archive_metadata( + files(result.comicinfo.decode(), result.metroninfo.decode()) + ) + assert not parsed.differences + assert parsed.issue.credits == issue.values.credits + + +def test_reconciliation_compares_only_representable_credit_roles(): + parsed = reconcile_archive_metadata( + files( + "<ComicInfo><Writer>Creator</Writer><Translator>Creator</Translator></ComicInfo>", + metron( + "<Credits><Credit><Creator>Creator</Creator><Roles><Role>Writer</Role>" + "<Role>Artist</Role><Role>Translator</Role></Roles></Credit></Credits>" + ), + ) + ) + assert not parsed.differences + assert parsed.issue.credits[0].role == "artist, translator, writer" + + +def test_metadata_comments_are_preserved_but_still_bounded(): + from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml + + root = parse_metadata_xml( + "<ComicInfo><!--note--><?local hint?></ComicInfo>", + root_name="ComicInfo", + preserve_misc=True, + ) + assert len(root) == 2 + with pytest.raises(MetadataXmlError, match="complexity_limit"): + parse_metadata_xml( + "<ComicInfo>" + "<!--note-->" * 4096 + "</ComicInfo>", + root_name="ComicInfo", + preserve_misc=True, + ) + + +def test_locg_release_references_are_preserved_not_emitted_as_canonical_issue_ids(): + incoming = files( + mi=metron( + '<IDS><ID source="Comic Vine" primary="true">7</ID>' + '<ID source="League of Comic Geeks">123</ID>' + '<ID source="League of Comic Geeks">456</ID></IDS>' + ) + ) + result = render_archive_metadata(*snapshots(), incoming) + assert {ref.external_id for ref in parse_metroninfo(result.metroninfo).release_references} == { + "123", + "456", + } + + +@pytest.mark.parametrize( + "content", + [ + "<GTIN><ISBN><Extension>local data</Extension></ISBN></GTIN>", + '<GTIN><UPC custom="keep">123</UPC></GTIN>', + ], +) +def test_schema_anytype_is_not_permission_to_keep_unsupported_extensions(content): + with pytest.raises(ArchiveMetadataRenderError, match="unsupported_content"): + render_archive_metadata(*snapshots(), files(mi=metron(content))) + + +@pytest.mark.parametrize( + "ci,mi", + [ + ('<ComicInfo><Pages><Page Image="0" unknown="keep" /></Pages></ComicInfo>', ""), + ("<ComicInfo><Title>AB", ""), + ("2026231", ""), + ], +) +def test_ambiguous_structured_values_do_not_get_replaced(ci, mi): + with pytest.raises(ArchiveMetadataRenderError): + render_archive_metadata(*snapshots(), files(ci, mi)) + + +@pytest.mark.parametrize( + "field,xml", + [ + ("title", 'Old title'), + ("publisher", 'Old publisher'), + ( + "credits", + 'Old creator' + "Writer", + ), + ], +) +def test_descriptive_override_cannot_relabel_opaque_resource_ids(field, xml): + series, issue = snapshots( + title="New title", credits=({"name": "New creator", "role": "writer"},) + ) + if field == "publisher": + series = series.model_copy(update={"origins": (origin(field, user=True),)}) + else: + issue = issue.model_copy(update={"origins": (origin(field, user=True),)}) + incoming = files(mi=metron('7' + xml)) + with pytest.raises(ArchiveMetadataRenderError, match="resource_metadata_change"): + render_archive_metadata(series, issue, incoming) + + +def test_unchanged_credit_resource_ids_survive_the_round_trip(): + series, issue = snapshots(credits=({"name": "Creator", "role": "writer"},)) + incoming = files( + mi=metron( + '7' + 'Creator' + 'Writer' + ) + ) + result = render_archive_metadata(series, issue, incoming) + assert ET.fromstring(result.metroninfo).find("Credits/Credit/Creator").get("id") == "88" + assert ET.fromstring(result.metroninfo).find("Credits/Credit/Roles/Role").get("id") == "2" + + +def test_old_schema_version_is_read_but_new_output_is_schema_11(): + incoming = files(mi=metron().replace("", '')) + result = render_archive_metadata(*snapshots(), incoming) + validate_metroninfo_xml(result.metroninfo) + assert "version" not in ET.fromstring(result.metroninfo).attrib + + +def test_unknown_role_is_reviewable_not_silently_changed_to_other(): + series, issue = snapshots(credits=({"name": "Creator", "role": "invented role"},)) + with pytest.raises(ArchiveMetadataRenderError, match="invalid_output"): + render_archive_metadata(series, issue, files()) + + +def test_primary_cannot_be_selected_from_observed_ids(): + series, issue = snapshots() + issue = issue.model_copy(update={"observed_identities": (METRON_ISSUE,)}) + with pytest.raises(ArchiveMetadataRenderError, match="invalid_primary_identity"): + render_archive_metadata(series, issue, files(), primary_identity=METRON_ISSUE) + + +def test_no_provider_identity_is_invented_for_local_metadata(): + series, issue = snapshots() + series = series.model_copy(update={"identities": ()}) + issue = issue.model_copy(update={"identities": ()}) + result = render_archive_metadata(series, issue, files()) + assert result.comicinfo and result.metroninfo + assert not parse_metroninfo(result.metroninfo).identities + assert ET.fromstring(result.metroninfo).find("Series").get("id") is None + + +def test_partial_calendar_is_preserved_without_inventing_a_day(): + result = render_archive_metadata( + *snapshots(), + files( + ci="20269", + ), + ) + ci = ET.fromstring(result.comicinfo) + assert ci.findtext("Year") == "2026" and ci.findtext("Month") == "9" + assert ci.find("Day") is None + assert parse_metroninfo(result.metroninfo).cover_date is None + + +def test_identity_comments_are_not_lost_during_id_normalization(): + incoming = files(mi=metron('7')) + result = render_archive_metadata(*snapshots(), incoming) + assert b"" in result.metroninfo + + +def test_known_source_alias_is_normalized_for_output_not_rejected_as_schema_drift(): + incoming = files(mi=metron('7')) + result = render_archive_metadata(*snapshots(), incoming) + assert b'source="Comic Vine"' in result.metroninfo + validate_metroninfo_xml(result.metroninfo) + + +def test_preserved_comments_cannot_disappear_when_replacing_a_rich_container(): + series, issue = snapshots(credits=()) + issue = issue.model_copy(update={"origins": (origin("credits", user=True),)}) + incoming = files( + mi=metron( + "Name" + "Writer" + ) + ) + with pytest.raises(ArchiveMetadataRenderError, match="unsupported_content"): + render_archive_metadata(series, issue, incoming) + + +@pytest.mark.parametrize( + "field,old,new,tag", + [ + ("series_type", "Single Issue", "Annual", "Format"), + ("issue_count", 12, 13, "Count"), + ], +) +def test_reviewed_series_fields_update_both_formats(field, old, new, tag): + series, issue = snapshots() + field_origin = origin(field, user=True) + if field == "issue_count": + field_origin = field_origin.model_copy(update={"domain": MetadataDomain.ISSUES}) + series = series.model_copy( + update={ + "values": series.values.model_copy(update={field: new}), + "origins": (field_origin,), + } + ) + result = render_archive_metadata( + series, + issue, + files(ci=f"<{tag}>{old}"), + ) + assert ET.fromstring(result.comicinfo).findtext(tag) == str(new) + parsed = parse_metroninfo(result.metroninfo) + assert (parsed.issue_count if field == "issue_count" else parsed.series_format) == new + + +def test_mismatched_baseline_cannot_authorize_a_refresh(): + series, issue = snapshots(title="New") + previous = issue.model_copy( + update={ + "identities": (METRON_ISSUE,), + "values": MetadataValues(title="Old"), + "origins": (origin("title"),), + } + ) + with pytest.raises(ArchiveMetadataRenderError, match="invalid_baseline"): + render_archive_metadata( + series, issue, files(), previous_issue=previous, replace_managed=True + ) + + +@pytest.mark.parametrize( + "tag,parent,child", + [ + ("Genre", "Genres", "Genre"), + ("Tags", "Tags", "Tag"), + ("Characters", "Characters", "Character"), + ("Teams", "Teams", "Team"), + ("Locations", "Locations", "Location"), + ], +) +def test_comicinfo_lists_are_carried_to_metron_without_making_up_ids(tag, parent, child): + result = render_archive_metadata( + *snapshots(), + files( + ci=f"<{tag}>First, Second", + ), + ) + nodes = ET.fromstring(result.metroninfo).findall(f"{parent}/{child}") + assert [node.text for node in nodes] == ["First", "Second"] + assert all(not node.attrib for node in nodes) + + +@pytest.mark.parametrize( + "ci_tag,mi_xml", + [ + ("Genre", "Other"), + ("Imprint", "PublisherOther"), + ("AgeRating", "Teen"), + ], +) +def test_conflicting_passthrough_fields_are_not_silently_chosen(ci_tag, mi_xml): + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata( + *snapshots(), + files( + ci=f"<{ci_tag}>First", + mi=metron(mi_xml), + ), + ) + + +def test_legacy_arc_names_and_positions_are_preserved_in_both_documents(): + result = render_archive_metadata( + *snapshots(), + files( + ci=( + "First, Second" + "2, 5" + ) + ), + ) + assert [(arc.name, arc.number) for arc in parse_metroninfo(result.metroninfo).arcs] == [ + ("First", 2), + ("Second", 5), + ] + + +@pytest.mark.parametrize( + "number", ["0", "-1", "2, 3", "9" * 5000], ids=["zero", "negative", "count", "oversized"] +) +def test_unrepresentable_arc_order_has_a_fixed_review_error(number): + with pytest.raises(ArchiveMetadataRenderError, match="unsupported_content"): + render_archive_metadata( + *snapshots(), + files( + ci=( + "First" + f"{number}" + ) + ), + ) + + +def test_arc_order_disagreement_requires_review(): + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata( + *snapshots(), + files( + ci="First2", + mi=metron("First3"), + ), + ) + + +def test_complementary_arc_order_fills_metron_gap_without_reidentification(): + result = render_archive_metadata( + *snapshots(), + files( + ci="First2", + mi=metron("First"), + ), + ) + assert parse_metroninfo(result.metroninfo).arcs[0].number == 2 + + +def test_counts_are_bounded_before_integer_conversion(): + with pytest.raises(ArchiveMetadataRenderError, match="invalid_input"): + render_archive_metadata( + *snapshots(), files(ci=("" + "9" * 5000 + "")) + ) + + +def test_explicit_empty_credits_remain_explicit_in_both_formats(): + result = render_archive_metadata(*snapshots(credits=()), files()) + parsed = reconcile_archive_metadata( + files(result.comicinfo.decode(), result.metroninfo.decode()) + ) + assert parsed.comicinfo.issue.credits == parsed.metroninfo.issue.credits == () + + +def test_real_archive_can_be_read_once_rendered_and_left_unchanged(tmp_path): + import hashlib + import zipfile + + from pullbox.core.archive_metadata import read_open_zip_metadata + + path = tmp_path / "example.cbz" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"untouched image fixture") + archive.writestr("ComicInfo.xml", "Local title") + before = hashlib.sha256(path.read_bytes()).digest() + with zipfile.ZipFile(path) as archive: + source = read_open_zip_metadata(archive) + result = render_archive_metadata(*snapshots(title="Local title"), source) + assert parse_metroninfo(result.metroninfo).stories == ("Local title",) + assert hashlib.sha256(path.read_bytes()).digest() == before + + +def test_passive_primary_context_and_series_id_survive_without_issue_attachment(): + series, issue = snapshots() + passive_series = ExternalIdentityRef(Namespace.LOCG, Kind.SERIES, "99") + series = series.model_copy(update={"identities": (passive_series,)}) + issue = issue.model_copy(update={"identities": ()}) + incoming = files( + mi=metron( + '123', + attrs='id="99"', + ) + ) + result = render_archive_metadata(series, issue, incoming) + parsed = parse_metroninfo(result.metroninfo) + assert parsed.primary_source == Namespace.LOCG + assert {e.identity for e in parsed.evidence} == {passive_series} + assert parsed.release_references[0].external_id == "123" + + +def test_managed_rich_credit_refresh_compares_the_comicinfo_projection(): + series, previous = snapshots(credits=({"name": "Old", "role": "writer, artist"},)) + previous = previous.model_copy(update={"origins": (origin("credits"),)}) + initial = render_archive_metadata(series, previous, files()) + incoming = files(initial.comicinfo.decode(), initial.metroninfo.decode()) + _, issue = snapshots(credits=({"name": "New", "role": "writer, artist"},)) + issue = issue.model_copy(update={"origins": (origin("credits"),)}) + result = render_archive_metadata( + series, issue, incoming, previous_issue=previous, replace_managed=True + ) + assert ET.fromstring(result.comicinfo).findtext("Writer") == "New" + assert parse_metroninfo(result.metroninfo).credits[0].creator.value == "New" + + +@pytest.mark.parametrize( + "canonical,expected", + [ + ("standard", "Single Issue"), + ("tpb", "Trade Paperback"), + ("hardcover", "Hardcover"), + ("one_shot", "One-Shot"), + ("annual", "Annual"), + ("omnibus", "Omnibus"), + ("graphic_novel", "Graphic Novel"), + ("Limited Series", "Limited Series"), + ], +) +def test_canonical_application_types_serialize_as_schema_formats(canonical, expected): + series, issue = snapshots() + series = series.model_copy( + update={"values": series.values.model_copy(update={"series_type": canonical})} + ) + result = render_archive_metadata(series, issue, files()) + assert ET.fromstring(result.comicinfo).findtext("Format") == expected + assert parse_metroninfo(result.metroninfo).series_format == expected + assert ( + render_archive_metadata( + series, issue, files(result.comicinfo.decode(), result.metroninfo.decode()) + ) + == result + ) + + +def test_generic_type_does_not_erase_a_more_specific_existing_format(): + series, issue = snapshots() + series = series.model_copy( + update={"values": series.values.model_copy(update={"series_type": "standard"})} + ) + incoming = files( + mi=metron(series="Example & FriendsLimited Series") + ) + with pytest.raises(ArchiveMetadataRenderError, match="unreconciled_field"): + render_archive_metadata(series, issue, incoming) + + +@pytest.mark.parametrize("kind", ["special", "compendium", "deluxe", "volume"]) +def test_types_without_a_metron_equivalent_remain_in_comicinfo(kind): + series, issue = snapshots() + series = series.model_copy( + update={"values": series.values.model_copy(update={"series_type": kind})} + ) + result = render_archive_metadata(series, issue, files()) + assert ET.fromstring(result.comicinfo).findtext("Format") == kind.title() + assert ET.fromstring(result.metroninfo).find("Series/Format") is None + validate_metroninfo_xml(result.metroninfo) + + +def test_schema_year_uses_four_digits_without_changing_the_canonical_year(): + series, issue = snapshots() + series = series.model_copy( + update={"values": series.values.model_copy(update={"year_start": 1})} + ) + result = render_archive_metadata(series, issue, files()) + assert ET.fromstring(result.metroninfo).findtext("Series/StartYear") == "0001" From 2a87dd3a4a75975f4245a00e6f0a3d44323ba94c Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 00:15:21 -0700 Subject: [PATCH 63/97] feat(metadata): write paired CBZ metadata atomically --- docs/development/ARCHITECTURE_OVERVIEW.md | 18 +- .../services/archive_metadata_writing.py | 325 ++++++++++++ tests/unit/test_archive_metadata_writing.py | 486 ++++++++++++++++++ 3 files changed, 827 insertions(+), 2 deletions(-) create mode 100644 src/pullbox/services/archive_metadata_writing.py create mode 100644 tests/unit/test_archive_metadata_writing.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 2066be4c..dd8183fa 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -505,11 +505,25 @@ creating provider identities or canonical membership. Unknown extensions and unrepresentable values stop for review instead of disappearing. Output passes the offline XSD and a shared-value cross-check before either document is returned. This pure renderer makes no provider/database calls or archive-file mutations; -the offline validator loads only its bundled schema resource. The single -archive rewrite/conversion path, canonical rich-field/membership expansion, +the offline validator loads only its bundled schema resource. Production +archive rewrite/conversion integration, canonical rich-field/membership expansion, production consumer migration and managed-root authorization remain required; legacy writers have not switched to this boundary yet. +`services/archive_metadata_writing.py` provides the coordinated CBZ publication +primitive for independently authorized callers. It reads both XML members from +one source ZIP session, renders the pair, streams approved entries into one +same-directory temporary archive, verifies its manifest and payload CRCs, and +publishes without overwriting an existing destination. Explicit same-path +refresh uses atomic replacement after closing the source handle. Source/staging +identity checks reject detected changes; invoking workflows still own per-file +serialization, binding, managed-root authorization, rollback journals and +interruptible process cleanup. Cooperative cancellation is checked through +streaming, verification and immediately before publication, never after a +successful commit point. Reference-only/keep-in-place callers are not authorized +by this primitive. Production caller migration and single-construction non-ZIP +conversion remain outstanding. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/services/archive_metadata_writing.py b/src/pullbox/services/archive_metadata_writing.py new file mode 100644 index 00000000..8f249973 --- /dev/null +++ b/src/pullbox/services/archive_metadata_writing.py @@ -0,0 +1,325 @@ +"""Single-pass CBZ metadata materialization for independently authorized callers.""" + +import os +import stat +import tempfile +import zipfile +from collections.abc import Callable +from copy import copy +from pathlib import Path, PurePosixPath +from typing import BinaryIO + +import structlog + +from pullbox.core.archive_metadata import read_open_zip_metadata +from pullbox.core.file_publication import publish_file_without_overwrite +from pullbox.core.file_safety import ( + DANGEROUS_EXTENSIONS, + FileSafetyError, + has_archive_member_path_traversal, +) +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_rendering import render_archive_metadata + +ArchiveMetadataProgress = Callable[[str, int, int, str], None] +_CHUNK_BYTES = 1024 * 1024 +_METADATA_NAMES = {"comicinfo.xml", "metroninfo.xml"} +logger = structlog.get_logger(__name__) + + +def write_cbz_metadata( + source_path: Path, + target_path: Path, + series: MetadataSnapshot, + issue: MetadataSnapshot, + *, + max_uncompressed_bytes: int, + replace_source: bool = False, + block_dangerous: bool = True, + temp_path: Path | None = None, + progress_callback: ArchiveMetadataProgress | None = None, + check_cancelled: Callable[[], None] | None = None, + primary_identity: ExternalIdentityRef | None = None, + previous_series: MetadataSnapshot | None = None, + previous_issue: MetadataSnapshot | None = None, + replace_managed: bool = False, +) -> bool: + """Write a verified pair in one CBZ construction, off the event loop. + + Paths must already be authorized and file/issue/parent binding verified by + the workflow. This primitive grants no managed ownership and does not remove + a copied source. Callers retain journal/rollback and per-file serialization. + Only an explicit same-path refresh may replace a file; other destinations + use exclusive publication. Cooperative cancellation ends before publication. + """ + if isinstance(max_uncompressed_bytes, bool) or max_uncompressed_bytes <= 0: + raise ValueError("Archive budget must be positive") + source_path = source_path.absolute() + target_path = target_path.absolute() + refresh = source_path == target_path + if replace_source != refresh: + raise ValueError("Source replacement must explicitly target the same path") + if target_path.suffix.casefold() != ".cbz": + raise ValueError("Metadata output requires a CBZ target") + if temp_path is not None: + temp_path = temp_path.absolute() + if temp_path.parent.resolve() != target_path.parent.resolve(): + raise ValueError("Temporary output must share the target directory") + if temp_path in {source_path, target_path}: + raise ValueError("Temporary output must not be the source or target") + if not refresh and os.path.lexists(target_path): + raise FileExistsError(target_path) + _check_cancelled(check_cancelled) + before = source_path.lstat() + if not stat.S_ISREG(before.st_mode): + raise FileSafetyError("Metadata source must be a regular file, not a link") + if refresh and not before.st_mode & 0o222: + raise FileSafetyError("Cannot replace a read-only source archive") + fd = os.open( + source_path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), + ) + with os.fdopen(fd, "rb") as source_stream: + _check_source(source_path, source_stream, before) + with zipfile.ZipFile(source_stream, "r") as source: + entries = source.infolist() + _validate_members(entries, max_uncompressed_bytes, block_dangerous) + files = read_open_zip_metadata(source) + pair = render_archive_metadata( + series, + issue, + files, + primary_identity=primary_identity, + previous_series=previous_series, + previous_issue=previous_issue, + replace_managed=replace_managed, + ) + outputs = {"ComicInfo.xml": pair.comicinfo, "MetronInfo.xml": pair.metroninfo} + members = [entry for entry in entries if not _metadata_member(entry)] + if ( + refresh + and files.comicinfo.payload == pair.comicinfo + and files.metroninfo.payload == pair.metroninfo + and {entry.filename for entry in entries if _metadata_member(entry)} == set(outputs) + ): + _check_cancelled(check_cancelled) + _check_source(source_path, source_stream, before) + return False + total_bytes = sum(entry.file_size for entry in members) + sum( + map(len, outputs.values()) + ) + if total_bytes > max_uncompressed_bytes: + raise FileSafetyError( + "Archive decompressed size exceeds limit after metadata rendering" + ) + _check_cancelled(check_cancelled) + stage, stage_fd = _create_stage(target_path, temp_path) + stage_identity = os.fstat(stage_fd) + try: + with os.fdopen(stage_fd, "w+b") as output_stream: + manifest = _construct( + source, + output_stream, + members, + outputs, + total_bytes, + progress_callback, + check_cancelled, + ) + output_stream.flush() + os.fsync(output_stream.fileno()) + _verify( + stage, manifest, source.comment, total_bytes, progress_callback, check_cancelled + ) + current_stage = stage.lstat() + if (current_stage.st_dev, current_stage.st_ino) != ( + stage_identity.st_dev, + stage_identity.st_ino, + ): + raise FileSafetyError("Temporary archive changed before publication") + if refresh: + # Do not copy ownership/xattrs/timestamps: NAS mounts may prohibit them. + stage.chmod(stat.S_IMODE(before.st_mode) & 0o777) + ready = stage.lstat() + _check_source(source_path, source_stream, before) + # Windows will not replace an open source. All input is consumed now. + source.close() + source_stream.close() + _progress(progress_callback, check_cancelled, "publishing", 0, 1, "files") + _check_source(source_path, None, before) + if _fingerprint(stage.lstat()) != _fingerprint(ready): + raise FileSafetyError("Temporary archive changed before publication") + if refresh: + os.replace(stage, target_path) + else: + try: + publish_file_without_overwrite(stage, target_path) + except OSError: + # The link fallback can publish successfully before unlink fails. + # Only our verified inode proves that the commit point was crossed. + if not _same_regular_file(target_path, ready): + raise + logger.warning("archive_metadata_published_with_staging_link") + # No fallible callback after the commit point: success must remain success. + return True + finally: + try: + if _same_regular_file(stage, stage_identity): + stage.unlink() + except OSError as exc: + logger.warning("archive_metadata_stage_cleanup_failed", errno=exc.errno) + + +def _same_regular_file(path: Path, expected: os.stat_result) -> bool: + try: + current = path.lstat() + except FileNotFoundError: + return False + return stat.S_ISREG(current.st_mode) and (current.st_dev, current.st_ino) == ( + expected.st_dev, + expected.st_ino, + ) + + +def _metadata_member(entry: zipfile.ZipInfo) -> bool: + return PurePosixPath(entry.filename.replace("\\", "/")).name.casefold() in _METADATA_NAMES + + +def _validate_members(entries: list[zipfile.ZipInfo], limit: int, block_dangerous: bool) -> None: + seen: set[str] = set() + total = 0 + for entry in entries: + name = entry.filename.replace("\\", "/") + mode = stat.S_IFMT(entry.external_attr >> 16) + expected_mode = stat.S_IFDIR if entry.is_dir() else stat.S_IFREG + if ( + has_archive_member_path_traversal(entry.orig_filename) + or "\x00" in entry.orig_filename + or mode not in {0, expected_mode} + or entry.flag_bits & 1 + or entry.file_size < 0 + or entry.compress_size < 0 + or (entry.is_dir() and entry.file_size != 0) + or (block_dangerous and PurePosixPath(name).suffix.casefold() in DANGEROUS_EXTENSIONS) + ): + raise FileSafetyError("Archive contains an unsafe or unsupported member") + key = name.casefold() + if key in seen: + raise FileSafetyError("Archive contains duplicate member paths") + seen.add(key) + total += entry.file_size + if total > limit: + raise FileSafetyError("Archive decompressed size exceeds limit") + + +def _create_stage(target: Path, requested: Path | None) -> tuple[Path, int]: + if requested is not None: + return requested, os.open(requested, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600) + fd, name = tempfile.mkstemp(prefix=".pullbox-metadata-", suffix=".cbz", dir=target.parent) + return Path(name), fd + + +def _fingerprint(info: os.stat_result) -> tuple[int, ...]: + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_mode, + ) + + +def _check_source(path: Path, stream: BinaryIO | None, before: os.stat_result) -> None: + try: + unchanged = _fingerprint(before) == _fingerprint(path.lstat()) + if stream is not None: + unchanged = unchanged and _fingerprint(before) == _fingerprint( + os.fstat(stream.fileno()) + ) + except FileNotFoundError: + unchanged = False + if not unchanged: + raise FileSafetyError("Source archive changed during metadata writing") + + +def _check_cancelled(check: Callable[[], None] | None) -> None: + if check is not None: + check() + + +def _progress( + callback: ArchiveMetadataProgress | None, + check: Callable[[], None] | None, + stage: str, + current: int, + total: int, + unit: str = "bytes", +) -> None: + _check_cancelled(check) + if callback is not None: + callback(stage, current, total, unit) + _check_cancelled(check) + + +def _construct( + source: zipfile.ZipFile, + stream: BinaryIO, + members: list[zipfile.ZipInfo], + metadata: dict[str, bytes], + total: int, + progress: ArchiveMetadataProgress | None, + check: Callable[[], None] | None, +) -> list[tuple[str, int, int]]: + current = 0 + _progress(progress, check, "transferring", 0, total) + with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as target: + target.comment = source.comment + for entry in members: + _check_cancelled(check) + with ( + source.open(entry) as incoming, + target.open(copy(entry), "w", force_zip64=True) as outgoing, + ): + while chunk := incoming.read(_CHUNK_BYTES): + _check_cancelled(check) + outgoing.write(chunk) + current += len(chunk) + if current > total: + raise FileSafetyError("Archive expanded beyond its declared size") + _progress(progress, check, "transferring", current, total) + for name, payload in metadata.items(): + _check_cancelled(check) + target.writestr(name, payload) + current += len(payload) + _progress(progress, check, "transferring", current, total) + if current != total: + raise FileSafetyError("Archive payload size disagrees with its directory") + manifest = [(entry.filename, entry.file_size, entry.CRC) for entry in target.infolist()] + return manifest + + +def _verify( + path: Path, + manifest: list[tuple[str, int, int]], + comment: bytes, + total: int, + progress: ArchiveMetadataProgress | None, + check: Callable[[], None] | None, +) -> None: + _progress(progress, check, "verifying", 0, total) + with zipfile.ZipFile(path) as archive: + if archive.comment != comment or manifest != [ + (entry.filename, entry.file_size, entry.CRC) for entry in archive.infolist() + ]: + raise FileSafetyError("Temporary archive failed verification") + current = 0 + for entry in archive.infolist(): + _check_cancelled(check) + with archive.open(entry) as stream: + while chunk := stream.read(_CHUNK_BYTES): + current += len(chunk) + _progress(progress, check, "verifying", current, total) + if current != total: + raise FileSafetyError("Temporary archive failed verification") diff --git a/tests/unit/test_archive_metadata_writing.py b/tests/unit/test_archive_metadata_writing.py new file mode 100644 index 00000000..1332ade3 --- /dev/null +++ b/tests/unit/test_archive_metadata_writing.py @@ -0,0 +1,486 @@ +"""Actual ZIP bytes exercise publication, bounded streaming and failure cleanup.""" + +import os +import stat +import zipfile +from pathlib import Path +from unittest.mock import patch +from xml.etree import ElementTree as ET + +import pytest +from structlog.testing import capture_logs + +from pullbox.core.exceptions import JobCancelledError +from pullbox.core.file_safety import FileSafetyError +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metroninfo import parse_metroninfo +from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.services import archive_metadata_writing as writing +from pullbox.services.archive_metadata_rendering import ArchiveMetadataRenderError + + +def snapshots(): + return ( + MetadataSnapshot( + entity_kind=MetadataEntityKind.SERIES, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42"), + ), + values=MetadataValues(title="Example", publisher="Publisher"), + ), + MetadataSnapshot( + entity_kind=MetadataEntityKind.ISSUE, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.ISSUE, "7"), + ), + values=MetadataValues(issue_number_text="50-x"), + ), + ) + + +def archive(path, *extras): + with zipfile.ZipFile(path, "w", zipfile.ZIP_STORED) as zf: + zf.comment = b"Preserved archive comment" + page = zipfile.ZipInfo("pages/001.jpg", (2020, 1, 2, 3, 4, 6)) + page.comment = b"Page comment" + page.external_attr = (stat.S_IFREG | 0o644) << 16 + zf.writestr(page, b"page bytes" * 200_000) + for name, data in extras: + zf.writestr(name, data) + return path + + +def write(source, target, **kwargs): + return writing.write_cbz_metadata( + source, target, *snapshots(), max_uncompressed_bytes=20_000_000, **kwargs + ) + + +def assert_pair(path): + with zipfile.ZipFile(path) as zf: + assert zf.testzip() is None + ci = ET.fromstring(zf.read("ComicInfo.xml")) + mi_bytes = zf.read("MetronInfo.xml") + validate_metroninfo_xml(mi_bytes) + mi = parse_metroninfo(mi_bytes) + assert ci.findtext("Series") == mi.series == "Example" + assert ci.findtext("Number") == mi.number == "50-x" + + +def test_copy_renders_pair_in_one_target_construction_and_one_source_open(tmp_path): + source = archive(tmp_path / "source.cbz") + target = tmp_path / "target.cbz" + original = source.read_bytes() + calls = [] + original_init = zipfile.ZipFile.__init__ + + def tracked(self, file, mode="r", *args, **kwargs): + calls.append((getattr(file, "name", file), mode)) + original_init(self, file, mode, *args, **kwargs) + + with patch.object(zipfile.ZipFile, "__init__", tracked): + assert write(source, target) + assert len([mode for _path, mode in calls if mode in {"w", "x", "a"}]) == 1 + assert len([mode for _path, mode in calls if mode == "r"]) == 2 # source + verification + assert source.read_bytes() == original + assert_pair(target) + with zipfile.ZipFile(source) as before, zipfile.ZipFile(target) as after: + assert before.read("pages/001.jpg") == after.read("pages/001.jpg") + assert before.comment == after.comment + b, a = before.getinfo("pages/001.jpg"), after.getinfo("pages/001.jpg") + assert (b.date_time, b.comment, b.external_attr) == ( + a.date_time, + a.comment, + a.external_attr, + ) + assert set(tmp_path.iterdir()) == {source, target} + + +def test_refresh_is_opt_in_and_preserves_mode_and_other_hardlink(tmp_path): + source = archive(tmp_path / "source.cbz") + source.chmod(0o640) + linked = tmp_path / "linked.cbz" + os.link(source, linked) + original = source.read_bytes() + with pytest.raises(ValueError, match="replacement"): + write(source, source) + assert source.read_bytes() == original + assert write(source, source, replace_source=True) + assert stat.S_IMODE(source.stat().st_mode) == 0o640 + assert linked.read_bytes() == original + assert_pair(source) + + +def test_refresh_second_pass_is_noop(tmp_path): + source = archive(tmp_path / "source.cbz") + assert write(source, source, replace_source=True) + before = source.stat() + original = source.read_bytes() + assert not write(source, source, replace_source=True) + assert source.read_bytes() == original + assert source.stat().st_ino == before.st_ino + assert source.stat().st_mtime_ns == before.st_mtime_ns + + +def test_nested_case_insensitive_metadata_becomes_one_root_pair(tmp_path): + source = archive(tmp_path / "source.cbz", ("meta/cOMICiNFO.XML", b"")) + target = tmp_path / "target.cbz" + assert write(source, target) + assert_pair(target) + with zipfile.ZipFile(target) as zf: + assert zf.namelist() == ["pages/001.jpg", "ComicInfo.xml", "MetronInfo.xml"] + + +@pytest.mark.parametrize( + "members", + [ + [("ComicInfo.xml", b"[cv_issue_id:99]")], + [("ComicInfo.xml", b"Other series")], + [("MetronInfo.xml", b""), ("metadata/ComicInfo.xml", b"")], + ], +) +def test_metadata_conflict_or_unreadable_preserves_original_and_no_target(tmp_path, members): + source = archive(tmp_path / "source.cbz", *members) + original = source.read_bytes() + target = tmp_path / "target.cbz" + with pytest.raises(ArchiveMetadataRenderError): + write(source, target) + assert source.read_bytes() == original + assert set(tmp_path.iterdir()) == {source} + + +@pytest.mark.parametrize("name", ["../outside.jpg", "/absolute.jpg", "C:\\bad.jpg", "run.exe"]) +def test_unsafe_members_fail_before_any_publication(tmp_path, name): + source = archive(tmp_path / "source.cbz", (name, b"bad")) + before = source.read_bytes() + with pytest.raises(FileSafetyError): + write(source, tmp_path / "target.cbz") + assert source.read_bytes() == before + assert set(tmp_path.iterdir()) == {source} + + +@pytest.mark.parametrize("mode", [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]) +def test_special_archive_members_are_not_copied(tmp_path, mode): + member = zipfile.ZipInfo("link.jpg") + member.create_system = 3 + member.external_attr = (mode | 0o644) << 16 + source = archive(tmp_path / "source.cbz", (member, b"target")) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "target.cbz") + assert set(tmp_path.iterdir()) == {source} + + +def test_configured_dangerous_extension_opt_out_does_not_disable_traversal(tmp_path): + source = archive(tmp_path / "source.cbz", ("legacy.js", b"legacy text")) + assert write(source, tmp_path / "allowed.cbz", block_dangerous=False) + source = archive(source, ("../legacy.js", b"bad")) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "blocked.cbz", block_dangerous=False) + + +def test_size_budget_is_enforced_before_streaming(tmp_path): + source = archive(tmp_path / "source.cbz") + with pytest.raises(FileSafetyError, match=r"decompressed size.*exceeds limit"): + writing.write_cbz_metadata( + source, tmp_path / "target.cbz", *snapshots(), max_uncompressed_bytes=10 + ) + assert set(tmp_path.iterdir()) == {source} + + +@pytest.mark.parametrize("limit", [0, -1, True]) +def test_invalid_budget_is_not_an_unlimited_override(tmp_path, limit): + source = archive(tmp_path / "source.cbz") + with pytest.raises(ValueError, match="positive"): + writing.write_cbz_metadata( + source, tmp_path / "target.cbz", *snapshots(), max_uncompressed_bytes=limit + ) + + +def test_member_reads_are_bounded_and_progress_is_monotonic(tmp_path): + source = archive(tmp_path / "source.cbz") + seen, progress = [], [] + read = zipfile.ZipExtFile.read + + def bounded(self, n=-1): + seen.append(n) + assert 0 < n <= 2 * 1024 * 1024 + 1 + return read(self, n) + + with patch.object(zipfile.ZipExtFile, "read", bounded): + assert write( + source, tmp_path / "target.cbz", progress_callback=lambda *v: progress.append(v) + ) + assert len(seen) > 4 + for stage in ("transferring", "verifying"): + samples = [(current, total) for s, current, total, unit in progress if s == stage] + assert len(samples) > 1 + assert samples[0][0] == 0 + assert samples[-1][0] == samples[-1][1] + assert all(0 <= current <= total for current, total in samples) + assert [c for c, _t in samples] == sorted(c for c, _t in samples) + + +@pytest.mark.parametrize("stage", ["transferring", "verifying", "publishing"]) +@pytest.mark.parametrize("refresh", [False, True]) +def test_cancellation_at_any_stage_preserves_original(tmp_path, stage, refresh): + source = archive(tmp_path / "source.cbz") + original = source.read_bytes() + target = source if refresh else tmp_path / "target.cbz" + + def cancel(current_stage, *_args): + if stage == current_stage: + raise JobCancelledError("Cancelled") + + with pytest.raises(JobCancelledError): + write(source, target, replace_source=refresh, progress_callback=cancel) + assert source.read_bytes() == original + assert set(tmp_path.iterdir()) == {source} + + +def test_pre_cancelled_operation_never_opens_archive(tmp_path): + source = archive(tmp_path / "source.cbz") + + def cancelled(): + raise JobCancelledError("Cancelled") + + with ( + patch.object(zipfile, "ZipFile", side_effect=AssertionError("should not open")), + pytest.raises(JobCancelledError), + ): + write(source, tmp_path / "target.cbz", check_cancelled=cancelled) + + +def test_existing_target_and_temp_are_never_overwritten(tmp_path): + source = archive(tmp_path / "source.cbz") + target, temp = tmp_path / "target.cbz", tmp_path / "stage.cbz" + target.write_bytes(b"existing") + with pytest.raises(FileExistsError): + write(source, target) + assert target.read_bytes() == b"existing" + target.unlink() + temp.write_bytes(b"existing stage") + with pytest.raises(FileExistsError): + write(source, target, temp_path=temp) + assert temp.read_bytes() == b"existing stage" + assert not target.exists() + + +def test_concurrent_target_creation_is_not_clobbered(tmp_path): + source = archive(tmp_path / "source.cbz") + target = tmp_path / "target.cbz" + + def race(stage, *_args): + if stage == "publishing": + target.write_bytes(b"another writer") + + with pytest.raises(FileExistsError): + write(source, target, progress_callback=race) + assert target.read_bytes() == b"another writer" + assert set(tmp_path.iterdir()) == {source, target} + + +@pytest.mark.parametrize("replace", [False, True]) +def test_source_changed_before_publish_is_not_overwritten_or_used(tmp_path, replace): + source = archive(tmp_path / "source.cbz") + target = source if replace else tmp_path / "target.cbz" + + def race(stage, *_args): + if stage == "publishing": + source.write_bytes(b"changed source") + + with pytest.raises(FileSafetyError, match="changed"): + write(source, target, replace_source=replace, progress_callback=race) + assert source.read_bytes() == b"changed source" + assert set(tmp_path.iterdir()) == {source} + + +def test_symlink_source_is_rejected(tmp_path): + actual = archive(tmp_path / "actual.cbz") + source = tmp_path / "source.cbz" + source.symlink_to(actual) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "target.cbz") + assert source.is_symlink() + + +def test_temp_path_must_be_in_target_directory(tmp_path): + source = archive(tmp_path / "source.cbz") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + with pytest.raises(ValueError, match="directory"): + write(source, tmp_path / "target.cbz", temp_path=elsewhere / "stage.cbz") + assert not list(elsewhere.iterdir()) + + +def test_bad_source_crc_never_publishes(tmp_path): + source = archive(tmp_path / "source.cbz") + data = source.read_bytes().replace(b"page bytes", b"bad! bytes", 1) + source.write_bytes(data) + with pytest.raises(zipfile.BadZipFile): + write(source, tmp_path / "target.cbz") + assert source.read_bytes() == data + assert set(tmp_path.iterdir()) == {source} + + +def test_failed_publication_preserves_source_and_cleans_stage(tmp_path): + source = archive(tmp_path / "source.cbz") + before = source.read_bytes() + with ( + patch("os.replace", side_effect=OSError("publication failed")), + pytest.raises(OSError, match="publication failed"), + ): + write(source, source, replace_source=True) + assert source.read_bytes() == before + assert set(tmp_path.iterdir()) == {source} + + +def test_publication_occurs_only_after_source_handle_closed(tmp_path): + source = archive(tmp_path / "source.cbz") + handles = [] + fdopen = os.fdopen + replace = os.replace + + def track(fd, mode, *args, **kwargs): + result = fdopen(fd, mode, *args, **kwargs) + handles.append(result) + return result + + def publish(stage, target): + assert all(handle.closed for handle in handles) + replace(stage, target) + + with patch("os.fdopen", track), patch("os.replace", publish): + assert write(source, source, replace_source=True) + assert_pair(source) + + +@pytest.mark.parametrize("link", [False, True]) +def test_swapped_stage_is_not_published_or_deleted(tmp_path, link): + source = archive(tmp_path / "source.cbz") + stage, impostor = tmp_path / "stage.cbz", tmp_path / "impostor.cbz" + before = source.read_bytes() + + def swap(current_stage, current, *_args): + if current_stage == "verifying" and current == 0: + stage.rename(impostor) + if link: + stage.symlink_to(impostor) + else: + stage.write_bytes(impostor.read_bytes()) + + with pytest.raises(FileSafetyError, match="changed"): + write(source, source, replace_source=True, temp_path=stage, progress_callback=swap) + assert source.read_bytes() == before + assert stage.exists() and impostor.exists() + assert stage.is_symlink() is link + + +def test_verification_detects_corrupt_temporary_output(tmp_path): + source = archive(tmp_path / "source.cbz") + stage = tmp_path / "stage.cbz" + before = source.read_bytes() + + def corrupt(current_stage, current, *_args): + if current_stage == "verifying" and current == 0: + stage.write_bytes(stage.read_bytes().replace(b"page bytes", b"bad! bytes", 1)) + + with pytest.raises(zipfile.BadZipFile): + write(source, source, replace_source=True, temp_path=stage, progress_callback=corrupt) + assert source.read_bytes() == before + assert set(tmp_path.iterdir()) == {source} + + +@pytest.mark.parametrize( + "names", [("page.jpg", "PAGE.jpg"), ("folder/page.jpg", "folder\\page.jpg")] +) +def test_colliding_member_names_fail_closed(tmp_path, names): + source = archive(tmp_path / "source.cbz", *((name, b"content") for name in names)) + with pytest.raises(FileSafetyError, match="duplicate"): + write(source, tmp_path / "target.cbz") + assert set(tmp_path.iterdir()) == {source} + + +def test_symlink_destination_is_not_replaced(tmp_path): + source = archive(tmp_path / "source.cbz") + target = tmp_path / "target.cbz" + target.symlink_to(tmp_path / "absent") + with pytest.raises(FileExistsError): + write(source, target) + assert target.is_symlink() + + +def test_metadata_bytes_are_included_in_output_budget(tmp_path): + source = archive(tmp_path / "source.cbz") + with zipfile.ZipFile(source) as zf: + budget = sum(item.file_size for item in zf.infolist()) + with pytest.raises(FileSafetyError, match="after metadata"): + writing.write_cbz_metadata( + source, tmp_path / "target.cbz", *snapshots(), max_uncompressed_bytes=budget + ) + assert set(tmp_path.iterdir()) == {source} + + +def test_published_fallback_target_remains_success_if_staging_unlink_fails(tmp_path): + source = archive(tmp_path / "source.cbz") + target, stage = tmp_path / "target.cbz", tmp_path / "stage.cbz" + unlink = Path.unlink + + def deny_stage(path, *args, **kwargs): + if path == stage: + raise PermissionError("staging cleanup denied") + return unlink(path, *args, **kwargs) + + with ( + patch("pullbox.core.file_publication._native_rename_without_overwrite", return_value=False), + patch.object(Path, "unlink", deny_stage), + capture_logs() as logs, + ): + assert write(source, target, temp_path=stage) + assert_pair(target) + assert target.stat().st_ino == stage.stat().st_ino + assert any(log["event"] == "archive_metadata_stage_cleanup_failed" for log in logs) + + +def test_fsync_failure_preserves_source_and_cleans_stage(tmp_path): + source = archive(tmp_path / "source.cbz") + before = source.read_bytes() + with patch("os.fsync", side_effect=OSError("disk unavailable")), pytest.raises(OSError): + write(source, source, replace_source=True) + assert source.read_bytes() == before + assert set(tmp_path.iterdir()) == {source} + + +def test_failed_metadata_write_preserves_source_and_cleans_stage(tmp_path): + source = archive(tmp_path / "source.cbz") + before = source.read_bytes() + with ( + patch.object(zipfile.ZipFile, "writestr", side_effect=OSError("disk full")), + pytest.raises(OSError), + ): + write(source, source, replace_source=True) + assert source.read_bytes() == before + assert set(tmp_path.iterdir()) == {source} + + +def test_optional_platform_open_flags_do_not_block_writing(tmp_path, monkeypatch): + source = archive(tmp_path / "source.cbz") + monkeypatch.delattr(os, "O_NOFOLLOW", raising=False) + monkeypatch.delattr(os, "O_NONBLOCK", raising=False) + assert write(source, tmp_path / "target.cbz") + assert_pair(tmp_path / "target.cbz") + + +def test_read_only_source_can_be_copied_but_not_refreshed(tmp_path): + source = archive(tmp_path / "source.cbz") + source.chmod(0o444) + original = source.read_bytes() + assert write(source, tmp_path / "copy.cbz") + with pytest.raises(FileSafetyError, match="read-only"): + write(source, source, replace_source=True) + assert source.read_bytes() == original From 0039bf2a326e34464ea571e6bebbefb7474de840 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 01:01:52 -0700 Subject: [PATCH 64/97] feat(metadata): stage paired archive writes in interruptible workers --- docs/development/ARCHITECTURE_OVERVIEW.md | 16 + .../executors/archive_metadata_staging.py | 256 +++++++++++ .../utilities/executors/archive_subprocess.py | 120 ++++- .../test_archive_metadata_staging.py | 409 ++++++++++++++++++ tests/utilities/test_archive_subprocess.py | 170 ++++++++ 5 files changed, 947 insertions(+), 24 deletions(-) create mode 100644 src/pullbox/utilities/executors/archive_metadata_staging.py create mode 100644 tests/utilities/test_archive_metadata_staging.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index dd8183fa..ae552f52 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -524,6 +524,22 @@ successful commit point. Reference-only/keep-in-place callers are not authorized by this primitive. Production caller migration and single-construction non-ZIP conversion remain outstanding. +`utilities/executors/archive_metadata_staging.py` hands paired CBZ work to the +interruptible archive worker without giving it a final library destination. +Canonical snapshots use a bounded private request file, not process arguments. +The worker produces one verified private archive; its fingerprint and the +original source fingerprint must still agree when the parent hands it off. +The async context removes its own workspace on failure, cancellation or exit, +without following a replaced workspace or deleting an artifact published out +of it. The owner must recheck current binding/ownership/policy, journal intent, +and revalidate the supplied fingerprints before publication. This context is +not a durable journal, permission to mutate reference files or a filesystem lock. +The shared supervisor reaps workers after task cancellation, including during +spawn and repeated cancellation, and shields pipe draining during kill escalation. +Legacy operations still publish inside their workers; they have not migrated to +the staged owner-publication contract. Production binding, journal recovery and +consumer migration remain required. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/utilities/executors/archive_metadata_staging.py b/src/pullbox/utilities/executors/archive_metadata_staging.py new file mode 100644 index 00000000..f67f1d5f --- /dev/null +++ b/src/pullbox/utilities/executors/archive_metadata_staging.py @@ -0,0 +1,256 @@ +"""Private paired-metadata staging for journal-owning workflows.""" + +import os +import shutil +import stat +import tempfile +import zipfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import structlog +from pydantic import BaseModel, ConfigDict, Field + +from pullbox.core.file_safety import FileSafetyError +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_rendering import ArchiveMetadataRenderError +from pullbox.services.archive_metadata_writing import write_cbz_metadata +from pullbox.utilities.executors.archive_subprocess import ( + ControlCheck, + ProgressCallback, + _dispatch_progress_callback, + _run_archive_operation, + _write_progress_state, +) + +_MAX_REQUEST_BYTES = 4 * 1024 * 1024 +logger = structlog.get_logger(__name__) +type FileFingerprint = tuple[int, int, int, int, int, int] + + +class _StagingRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + source_path: Path + source_fingerprint: FileFingerprint + series: MetadataSnapshot + issue: MetadataSnapshot + max_uncompressed_bytes: int = Field(gt=0, strict=True) + block_dangerous: bool = Field(default=True, strict=True) + primary_identity: ExternalIdentityRef | None = None + previous_series: MetadataSnapshot | None = None + previous_issue: MetadataSnapshot | None = None + replace_managed: bool = Field(default=False, strict=True) + + +class _StagingResult(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + fingerprint: FileFingerprint + + +class ArchiveMetadataStagingError(RuntimeError): + """Fixed worker diagnostic, without request contents or archive metadata.""" + + +@dataclass(frozen=True) +class StagedArchiveMetadata: + """Short-lived evidence, not path authorization or permission to replace a file.""" + + path: Path + source_path: Path + source_fingerprint: FileFingerprint + output_fingerprint: FileFingerprint + + def check_unchanged(self) -> None: + """Recheck file evidence immediately before the owner's commit boundary.""" + _check_fingerprint(self.source_path, self.source_fingerprint) + _check_fingerprint(self.path, self.output_fingerprint) + + +@asynccontextmanager +async def stage_cbz_metadata_interruptible( + source_path: Path, + staging_parent: Path, + series: MetadataSnapshot, + issue: MetadataSnapshot, + *, + max_uncompressed_bytes: int, + block_dangerous: bool = True, + cancellation_check: ControlCheck | None = None, + progress_callback: ProgressCallback | None = None, + primary_identity: ExternalIdentityRef | None = None, + previous_series: MetadataSnapshot | None = None, + previous_issue: MetadataSnapshot | None = None, + replace_managed: bool = False, +) -> AsyncIterator[StagedArchiveMetadata]: + """Stage a verified pair, then yield to the independently authorized owner. + + The worker is never given a final destination and cannot replace the source. + The caller must revalidate binding, policy and fingerprints, journal intent, + then publish within this context. Staging is removed on exit, including + cancellation, but never follows an artifact moved out by the owner. Use a + staging parent on the final target's filesystem for atomic publication. + This is not a restart journal or a lock against concurrent filesystem edits. + """ + if cancellation_check is not None: + await cancellation_check() + source_path = source_path.absolute() + request = _StagingRequest( + source_path=source_path, + source_fingerprint=_fingerprint(source_path), + series=series, + issue=issue, + max_uncompressed_bytes=max_uncompressed_bytes, + block_dangerous=block_dangerous, + primary_identity=primary_identity, + previous_series=previous_series, + previous_issue=previous_issue, + replace_managed=replace_managed, + ) + encoded = request.model_dump_json().encode("utf-8") + if len(encoded) > _MAX_REQUEST_BYTES: + raise ValueError("Metadata staging request exceeds limit") + directory = Path(tempfile.mkdtemp(prefix=".pullbox-metadata-stage-", dir=staging_parent)) + identity = directory.lstat() + try: + request_path = directory / "request.json" + fd = os.open(request_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(encoded) + result = await _run_archive_operation( + "paired_stage", + {"request_path": str(request_path)}, + cancellation_check=cancellation_check, + progress_callback=progress_callback, + progress_state_path=directory / "progress.json", + ) + try: + # JSON transports the fixed tuple as a list; all six elements stay strict integers. + if not isinstance(result, dict) or set(result) != {"fingerprint"}: + raise ValueError("Invalid worker result") + fingerprint = result["fingerprint"] + if not isinstance(fingerprint, list): + raise ValueError("Invalid worker fingerprint") + completed = _StagingResult(fingerprint=tuple(fingerprint)) + except (TypeError, ValueError): + raise ArchiveMetadataStagingError("worker_failed") from None + output = directory / "metadata.cbz" + prepared = StagedArchiveMetadata( + output, source_path, request.source_fingerprint, completed.fingerprint + ) + if progress_callback is not None: + await _dispatch_progress_callback(progress_callback, "ready", 1, 1, "files") + if cancellation_check is not None: + await cancellation_check() + prepared.check_unchanged() + yield prepared + finally: + _cleanup_workspace(directory, identity) + + +def _fingerprint(path: Path) -> FileFingerprint: + info = path.lstat() + if not stat.S_ISREG(info.st_mode): + raise FileSafetyError("Metadata source/output must be a regular file") + return _stat_fingerprint(info) + + +def _stat_fingerprint(info: os.stat_result) -> FileFingerprint: + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_mode, + ) + + +def _check_fingerprint(path: Path, expected: FileFingerprint) -> None: + try: + if _fingerprint(path) == expected: + return + except (FileNotFoundError, FileSafetyError): + pass + raise FileSafetyError("Archive changed during metadata staging") + + +def _cleanup_workspace(directory: Path, expected: os.stat_result) -> None: + try: + current = directory.lstat() + if stat.S_ISDIR(current.st_mode) and (current.st_dev, current.st_ino) == ( + expected.st_dev, + expected.st_ino, + ): + shutil.rmtree(directory) + else: + logger.warning("archive_metadata_workspace_changed") + except FileNotFoundError: + pass + except OSError as exc: + logger.warning("archive_metadata_workspace_cleanup_failed", errno=exc.errno) + + +def _read_request(path: Path) -> _StagingRequest: + before = _fingerprint(path) + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as stream: + if _stat_fingerprint(os.fstat(stream.fileno())) != before: + raise ValueError("Request changed") + encoded = stream.read(_MAX_REQUEST_BYTES + 1) + if len(encoded) > _MAX_REQUEST_BYTES: + raise ValueError("Request exceeds limit") + _check_fingerprint(path, before) + return _StagingRequest.model_validate_json(encoded) + + +def worker_stage_metadata(payload: dict[str, Any]) -> dict[str, Any]: + """Internal worker entry; only fixed output names under the private workspace.""" + try: + request_path = Path(payload["request_path"]) + request = _read_request(request_path) + except (OSError, ValueError, TypeError, KeyError, FileSafetyError): + raise ArchiveMetadataStagingError("invalid_plan") from None + directory = request_path.parent + try: + _check_fingerprint(request.source_path, request.source_fingerprint) + except FileSafetyError: + raise ArchiveMetadataStagingError("source_changed") from None + + def progress(stage: str, current: int, total: int, unit: str) -> None: + _write_progress_state( + directory / "progress.json", + "staging" if stage == "publishing" else stage, + current, + total, + unit, + ) + + try: + write_cbz_metadata( + request.source_path, + directory / "metadata.cbz", + request.series, + request.issue, + max_uncompressed_bytes=request.max_uncompressed_bytes, + block_dangerous=request.block_dangerous, + temp_path=directory / "constructing.cbz", + progress_callback=progress, + primary_identity=request.primary_identity, + previous_series=request.previous_series, + previous_issue=request.previous_issue, + replace_managed=request.replace_managed, + ) + _check_fingerprint(request.source_path, request.source_fingerprint) + except ArchiveMetadataRenderError: + raise ArchiveMetadataStagingError("metadata_conflict") from None + except (FileSafetyError, zipfile.BadZipFile): + raise ArchiveMetadataStagingError("unsafe_archive") from None + except (OSError, ValueError, RuntimeError): + raise ArchiveMetadataStagingError("worker_failed") from None + return {"fingerprint": list(_fingerprint(directory / "metadata.cbz"))} diff --git a/src/pullbox/utilities/executors/archive_subprocess.py b/src/pullbox/utilities/executors/archive_subprocess.py index 9e137ddb..cd04904f 100644 --- a/src/pullbox/utilities/executors/archive_subprocess.py +++ b/src/pullbox/utilities/executors/archive_subprocess.py @@ -24,11 +24,14 @@ from pathlib import Path from typing import Any, Literal, cast +import structlog + from pullbox.core.exceptions import JobCancelledError, JobPausedError ControlCheck = Callable[[], Awaitable[None]] ProgressCallback = Callable[[str, int, int, str], Awaitable[None] | None] -_ArchiveOperation = Literal["convert", "transfer", "embed", "materialize_embed"] +_ArchiveOperation = Literal["convert", "transfer", "embed", "materialize_embed", "paired_stage"] +logger = structlog.get_logger(__name__) _CONTROL_POLL_INTERVAL_SECONDS = 0.2 _PROCESS_TERMINATE_TIMEOUT_SECONDS = 2.0 @@ -253,16 +256,23 @@ async def _run_archive_operation( ) -> dict[str, Any]: stdout: bytes = b"" stderr: bytes = b"" - proc = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "pullbox.utilities.executors.archive_subprocess", - "--worker", - operation, - json.dumps(payload), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + spawn_task = asyncio.create_task( + asyncio.create_subprocess_exec( + sys.executable, + "-m", + "pullbox.utilities.executors.archive_subprocess", + "--worker", + operation, + json.dumps(payload), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) ) + try: + proc = await asyncio.shield(spawn_task) + except BaseException: + await _await_worker_cleanup(asyncio.create_task(_cleanup_spawned_worker(spawn_task))) + raise communicate_task = asyncio.create_task(proc.communicate()) last_reported_transfer = -1 last_reported_state: tuple[str, int, int, str] | None = None @@ -306,12 +316,7 @@ async def _run_archive_operation( await _dispatch_progress_callback(progress_callback, *progress_state) if cancellation_check is not None: - try: - await cancellation_check() - except (JobCancelledError, JobPausedError): - await _terminate_worker_process(proc, communicate_task) - _cleanup_paths(effective_cleanup_paths) - raise + await cancellation_check() if progress_callback is not None and progress_total is not None: final_bytes = progress_total if progress_path is not None: @@ -331,13 +336,19 @@ async def _run_archive_operation( progress_state = _read_progress_state(progress_state_path) if progress_state is not None and progress_state != last_reported_state: await _dispatch_progress_callback(progress_callback, *progress_state) - except Exception: - if proc.returncode is None: - await _terminate_worker_process(proc, communicate_task) + except BaseException as exc: + was_running = proc.returncode is None + await _await_worker_cleanup( + asyncio.create_task(_terminate_worker_process(proc, communicate_task)) + ) + if was_running and isinstance( + exc, (JobCancelledError, JobPausedError, asyncio.CancelledError) + ): + _cleanup_operation_paths(operation, payload, effective_cleanup_paths) raise if proc.returncode != 0: - _cleanup_paths(effective_cleanup_paths) + _cleanup_operation_paths(operation, payload, effective_cleanup_paths) _raise_worker_error(operation, payload, stdout, stderr, returncode=proc.returncode) try: @@ -347,7 +358,13 @@ async def _run_archive_operation( if payload_text: payload_text = payload_text.splitlines()[-1] decoded = json.loads(payload_text or "{}") - except json.JSONDecodeError as exc: + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + if operation == "paired_stage": + from pullbox.utilities.executors.archive_metadata_staging import ( + ArchiveMetadataStagingError, + ) + + raise ArchiveMetadataStagingError("worker_failed") from None raise RuntimeError( f"Archive worker returned invalid JSON for {operation}: {stdout!r}" ) from exc @@ -378,16 +395,40 @@ async def _terminate_worker_process( await communicate_task return - proc.terminate() + with contextlib.suppress(ProcessLookupError): + proc.terminate() try: - await asyncio.wait_for(communicate_task, timeout=_PROCESS_TERMINATE_TIMEOUT_SECONDS) + await asyncio.wait_for( + asyncio.shield(communicate_task), timeout=_PROCESS_TERMINATE_TIMEOUT_SECONDS + ) return except TimeoutError: - proc.kill() + with contextlib.suppress(ProcessLookupError): + proc.kill() with contextlib.suppress(Exception): await communicate_task +async def _cleanup_spawned_worker(task: asyncio.Task[asyncio.subprocess.Process]) -> None: + proc = await task + await _terminate_worker_process(proc, asyncio.create_task(proc.communicate())) + + +async def _await_worker_cleanup(task: asyncio.Task[None]) -> None: + # A second cancel must not abandon a live child or race directory cleanup. + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + continue + except Exception: + break + try: + task.result() + except Exception as exc: + logger.warning("archive_worker_cleanup_failed", error_type=type(exc).__name__) + + def _cleanup_paths(paths: list[Path]) -> None: for path in paths: if path.is_dir(): @@ -397,6 +438,17 @@ def _cleanup_paths(paths: list[Path]) -> None: path.unlink() +def _cleanup_operation_paths( + operation: _ArchiveOperation, payload: dict[str, Any], paths: list[Path] +) -> None: + if operation == "transfer" and payload.get("method") == "move": + # A stopped worker may already have removed the source. Its destination + # is recovery evidence, possibly the sole copy, never disposable output. + target = Path(str(payload["target"])).absolute() + paths = [path for path in paths if path.absolute() != target] + _cleanup_paths(paths) + + def _raise_worker_error( operation: _ArchiveOperation, payload: dict[str, Any], @@ -405,6 +457,22 @@ def _raise_worker_error( *, returncode: int | None = None, ) -> None: + if operation == "paired_stage": + from pullbox.utilities.executors.archive_metadata_staging import ( + ArchiveMetadataStagingError, + ) + + code = "worker_failed" + with contextlib.suppress(ValueError, TypeError, AttributeError): + diagnostic = json.loads(stderr).get("message") + if diagnostic in { + "invalid_plan", + "unsafe_archive", + "metadata_conflict", + "source_changed", + }: + code = diagnostic + raise ArchiveMetadataStagingError(code) details: dict[str, Any] | None = None raw = ( stderr.decode("utf-8", errors="replace").strip() @@ -501,6 +569,10 @@ def _worker_main(argv: list[str]) -> int: result = _worker_embed(payload) elif operation == "materialize_embed": result = _worker_materialize_embed(payload) + elif operation == "paired_stage": + from pullbox.utilities.executors.archive_metadata_staging import worker_stage_metadata + + result = worker_stage_metadata(payload) else: raise ValueError(f"Unsupported archive worker operation: {operation}") except Exception as exc: # pragma: no cover - exercised via parent wrapper diff --git a/tests/utilities/test_archive_metadata_staging.py b/tests/utilities/test_archive_metadata_staging.py new file mode 100644 index 00000000..e121df91 --- /dev/null +++ b/tests/utilities/test_archive_metadata_staging.py @@ -0,0 +1,409 @@ +"""Real workers must never publish the library artifact they are preparing.""" + +import asyncio +import json +import stat +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +import pytest + +from pullbox.core.exceptions import JobCancelledError, JobPausedError +from pullbox.core.file_publication import publish_file_without_overwrite +from pullbox.core.file_safety import FileSafetyError +from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, +) +from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.utilities.executors import archive_metadata_staging as staging +from pullbox.utilities.executors import archive_subprocess + + +def snapshots(description=None): + return ( + MetadataSnapshot( + entity_kind=MetadataEntityKind.SERIES, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42"), + ), + values=MetadataValues(title="Example", description=description), + ), + MetadataSnapshot( + entity_kind=MetadataEntityKind.ISSUE, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.ISSUE, "7"), + ), + values=MetadataValues(issue_number_text="50-x"), + ), + ) + + +@pytest.fixture +def source(tmp_path): + path = tmp_path / "source.cbz" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("001.jpg", b"page" * 200_000) + return path + + +def stage(source, **kwargs): + return staging.stage_cbz_metadata_interruptible( + source, source.parent, *snapshots(), max_uncompressed_bytes=4_000_000, **kwargs + ) + + +def assert_pair(path): + with zipfile.ZipFile(path) as archive: + assert archive.testzip() is None + assert archive.read("001.jpg") == b"page" * 200_000 + assert ET.fromstring(archive.read("ComicInfo.xml")).findtext("Number") == "50-x" + validate_metroninfo_xml(archive.read("MetronInfo.xml")) + + +async def test_real_worker_yields_private_verified_pair_and_never_replaces_source(source): + original = source.read_bytes() + events = [] + async with stage(source, progress_callback=lambda *event: events.append(event)) as prepared: + assert prepared.path != source + assert prepared.path.parent.parent == source.parent + assert stat.S_IMODE(prepared.path.parent.stat().st_mode) == 0o700 + assert stat.S_IMODE(prepared.path.stat().st_mode) == 0o600 + assert prepared.source_path == source + assert_pair(prepared.path) + prepared.check_unchanged() + assert source.read_bytes() == original + assert events[-1] == ("ready", 1, 1, "files") + assert all(event[0] != "publishing" for event in events) + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +async def test_owner_can_publish_after_journal_boundary_without_repacking(source): + target = source.parent / "library.cbz" + async with stage(source) as prepared: + prepared.check_unchanged() + publish_file_without_overwrite(prepared.path, target) + assert_pair(target) + assert target.exists() + assert set(source.parent.iterdir()) == {source, target} + + +async def test_failed_owner_or_progress_callback_leaves_source_unchanged(source): + original = source.read_bytes() + with pytest.raises(RuntimeError, match="journal unavailable"): + async with stage(source) as prepared: + assert prepared.path != source + raise RuntimeError("journal unavailable") + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +async def test_callback_failure_after_worker_completion_cleans_only_private_stage(source): + original = source.read_bytes() + + def fail(stage_name, *_args): + if stage_name == "ready": + raise RuntimeError("progress unavailable") + + with pytest.raises(RuntimeError, match="progress unavailable"): + async with stage(source, progress_callback=fail): + pytest.fail("failed progress must not hand off output") + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("control", [JobCancelledError, JobPausedError]) +async def test_control_before_launch_creates_nothing(source, monkeypatch, control): + async def fail_spawn(*_args, **_kwargs): + pytest.fail("cancelled work must not start a child") + + async def cancel(): + raise control("stop") + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", fail_spawn) + with pytest.raises(control): + async with stage(source, cancellation_check=cancel): + pytest.fail("cancelled work must not yield") + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("control", [JobCancelledError, JobPausedError]) +async def test_control_at_completed_stage_cannot_publish(source, control): + ready = False + original = source.read_bytes() + + def progress(name, *_args): + nonlocal ready + ready = name == "ready" + + async def cancel(): + if ready: + raise control("stop") + + with pytest.raises(control): + async with stage(source, cancellation_check=cancel, progress_callback=progress): + pytest.fail("pending control at handoff must win") + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("changed", ["source", "stage"]) +@pytest.mark.parametrize("change", ["write", "replace", "symlink", "remove"]) +async def test_owner_rechecks_changed_source_and_output(source, changed, change): + async with stage(source) as prepared: + path = source if changed == "source" else prepared.path + if change == "write": + path.write_bytes(b"changed") + else: + path.unlink() + if change == "replace": + path.write_bytes(b"replacement") + elif change == "symlink": + path.symlink_to("missing") + with pytest.raises(FileSafetyError, match="changed"): + prepared.check_unchanged() + + +async def test_source_changed_by_ready_callback_is_not_handed_off(source): + def change(name, *_args): + if name == "ready": + source.write_bytes(b"replacement") + + with pytest.raises(FileSafetyError, match="changed"): + async with stage(source, progress_callback=change): + pytest.fail("changed source must not be handed off") + assert source.read_bytes() == b"replacement" + assert list(source.parent.iterdir()) == [source] + + +async def test_large_snapshot_uses_private_request_not_process_arguments(source, monkeypatch): + spawn = asyncio.create_subprocess_exec + launches = [] + private = "private title annotation " * 8_000 + + async def capture(*args, **kwargs): + launches.append(args) + assert private not in repr(args) + request = Path(json.loads(args[-1])["request_path"]) + assert stat.S_IMODE(request.stat().st_mode) == 0o600 + assert private in request.read_text() + return await spawn(*args, **kwargs) + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", capture) + async with staging.stage_cbz_metadata_interruptible( + source, source.parent, *snapshots(private), max_uncompressed_bytes=4_000_000 + ) as prepared: + assert_pair(prepared.path) + assert len(launches) == 1 + assert len(repr(launches[0])) < 2_000 + assert list(source.parent.iterdir()) == [source] + + +async def test_request_budget_fails_before_worker_and_cleans(source, monkeypatch): + monkeypatch.setattr(staging, "_MAX_REQUEST_BYTES", 1, raising=False) + with pytest.raises(ValueError, match=r"request.*limit"): + async with stage(source): + pytest.fail("oversized request must be refused") + assert list(source.parent.iterdir()) == [source] + + +async def test_worker_failure_does_not_echo_private_metadata_or_leave_stage(source): + private = "DO NOT ECHO THIS PRIVATE VALUE" + with zipfile.ZipFile(source, "a") as archive: + archive.writestr("ComicInfo.xml", f"{private}") + original = source.read_bytes() + with pytest.raises(staging.ArchiveMetadataStagingError) as error: + async with stage(source): + pytest.fail("conflicting metadata must not yield") + assert private not in str(error.value) + assert str(source) not in str(error.value) + assert "metadata_conflict" in str(error.value) + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +async def test_real_worker_failure_enforces_archive_safety(source): + with zipfile.ZipFile(source, "a") as archive: + archive.writestr("../bad.jpg", b"bad") + original = source.read_bytes() + with pytest.raises(staging.ArchiveMetadataStagingError, match="unsafe_archive"): + async with stage(source): + pytest.fail("unsafe archive must not yield") + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +async def test_read_only_source_can_stage_without_mutation(source): + source.chmod(0o444) + before = source.read_bytes() + async with stage(source) as prepared: + assert prepared.path != source + assert_pair(prepared.path) + assert source.read_bytes() == before + assert stat.S_IMODE(source.stat().st_mode) == 0o444 + + +async def test_source_symlink_is_rejected(source): + linked = source.parent / "linked.cbz" + linked.symlink_to(source) + with pytest.raises(FileSafetyError, match="regular"): + async with stage(linked): + pytest.fail("link is not independent file ownership") + assert linked.is_symlink() + + +async def test_stage_cleanup_does_not_follow_replaced_workspace(source): + outside = source.parent / "unrelated" + outside.mkdir() + sentinel = outside / "keep" + sentinel.write_text("keep") + async with stage(source) as prepared: + assert prepared.path != source + directory = prepared.path.parent + moved = directory.with_name("moved") + directory.rename(moved) + directory.symlink_to(outside, target_is_directory=True) + assert sentinel.read_text() == "keep" + assert directory.is_symlink() + assert (moved / "metadata.cbz").exists() + + +async def test_worker_output_changed_before_parent_observes_it_is_rejected(source, monkeypatch): + run = staging._run_archive_operation + + async def changed(operation, payload, **kwargs): + result = await run(operation, payload, **kwargs) + output = Path(payload["request_path"]).parent / "metadata.cbz" + output.write_bytes(b"changed after verification") + return result + + monkeypatch.setattr(staging, "_run_archive_operation", changed) + with pytest.raises(FileSafetyError, match="changed"): + async with stage(source): + pytest.fail("must use the worker's verified fingerprint, not a new one") + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("output", [b"\xffPRIVATE", b"PRIVATE", b"[1]", b"null"]) +async def test_unreadable_worker_result_has_fixed_error(source, monkeypatch, output): + class Process: + returncode = 0 + + async def communicate(self): + return output, b"" + + async def launch(*_args, **_kwargs): + return Process() + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + with pytest.raises(staging.ArchiveMetadataStagingError, match="worker_failed") as error: + async with stage(source): + pytest.fail("invalid worker result must not be accepted") + assert "PRIVATE" not in str(error.value) + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("control", ["task", "cancel", "pause", "crash"]) +async def test_real_interrupted_worker_cannot_mutate_library_and_cleans_workspace( + source, monkeypatch, control +): + original = source.read_bytes() + spawn = asyncio.create_subprocess_exec + launched = asyncio.Event() + children = [] + marker = source.parent / "worker-ready" + + async def launch(*args, **kwargs): + request_path = json.loads(args[-1])["request_path"] + script = ( + "import pathlib,sys,os,time; " + "root=pathlib.Path(sys.argv[1]).parent; " + "(root/'constructing.cbz').write_bytes(b'partial'); " + f"pathlib.Path({str(marker)!r}).touch(); " + + ("os._exit(23)" if control == "crash" else "time.sleep(60)") + ) + child = await spawn(sys.executable, "-c", script, request_path, **kwargs) + children.append(child) + launched.set() + return child + + async def check(): + if marker.exists() and control in {"cancel", "pause"}: + raise (JobCancelledError if control == "cancel" else JobPausedError)("stop") + + async def prepare(): + async with stage(source, cancellation_check=check): + pytest.fail("interrupted worker must not yield") + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + task = asyncio.create_task(prepare()) + expected = { + "task": asyncio.CancelledError, + "cancel": JobCancelledError, + "pause": JobPausedError, + "crash": staging.ArchiveMetadataStagingError, + }[control] + try: + await asyncio.wait_for(launched.wait(), 5) + async with asyncio.timeout(5): + while not marker.exists(): + await asyncio.sleep(0.01) + if control == "task": + task.cancel() + with pytest.raises(expected): + await asyncio.wait_for(task, 5) + assert children[0].returncode is not None + assert source.read_bytes() == original + assert set(source.parent.iterdir()) == {source, marker} + finally: + if children and children[0].returncode is None: + children[0].kill() + await children[0].wait() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.parametrize("kind", ["symlink", "missing", "invalid", "oversized"]) +def test_worker_request_is_bounded_regular_and_private(tmp_path, monkeypatch, kind): + request = tmp_path / "request.json" + if kind == "symlink": + request.symlink_to(tmp_path / "absent") + elif kind == "invalid": + request.write_text('{"secret": "DO NOT ECHO"}') + elif kind == "oversized": + monkeypatch.setattr(staging, "_MAX_REQUEST_BYTES", 10) + request.write_bytes(b" " * 11) + with pytest.raises(staging.ArchiveMetadataStagingError) as error: + staging.worker_stage_metadata({"request_path": str(request)}) + assert str(error.value) == "invalid_plan" + + +def test_worker_rejects_plan_with_source_changed_since_parent_check(source): + request = staging._StagingRequest( + source_path=source, + source_fingerprint=staging._fingerprint(source), + series=snapshots()[0], + issue=snapshots()[1], + max_uncompressed_bytes=4_000_000, + ) + path = source.parent / "request.json" + path.write_text(request.model_dump_json()) + source.write_bytes(b"new file") + with pytest.raises(staging.ArchiveMetadataStagingError, match="source_changed"): + staging.worker_stage_metadata({"request_path": str(path)}) + assert not (source.parent / "metadata.cbz").exists() + + +@pytest.mark.parametrize("details", [b"PRIVATE", b'{"message": "PRIVATE"}', b'{"message": []}']) +def test_arbitrary_worker_failure_message_is_not_reflected(details): + with pytest.raises(staging.ArchiveMetadataStagingError) as error: + archive_subprocess._raise_worker_error( + "paired_stage", {"request_path": "PRIVATE"}, b"PRIVATE", details, returncode=1 + ) + assert str(error.value) == "worker_failed" diff --git a/tests/utilities/test_archive_subprocess.py b/tests/utilities/test_archive_subprocess.py index eaa4a532..39a2840c 100644 --- a/tests/utilities/test_archive_subprocess.py +++ b/tests/utilities/test_archive_subprocess.py @@ -5,6 +5,7 @@ import asyncio import json import shutil +import sys import zipfile from pathlib import Path from typing import Any @@ -404,6 +405,175 @@ async def fake_wait_for(_awaitable: object, timeout: float) -> object: assert proc.kill_called is True +@pytest.mark.parametrize("ignore_terminate", [False, True]) +async def test_task_cancellation_reaps_real_worker_before_returning( + tmp_path, monkeypatch, ignore_terminate +): + """Task.cancel is distinct from a polled utility cancel/pause exception.""" + import os + + if ignore_terminate and os.name == "nt": + pytest.skip("Windows termination cannot be ignored by a signal handler") + started = asyncio.Event() + captured = [] + spawn = asyncio.create_subprocess_exec + marker = tmp_path / "ready" + partial = tmp_path / "partial.cbz" + partial.write_bytes(b"partial") + script = ( + "import signal,time,pathlib; " + + ("signal.signal(signal.SIGTERM, signal.SIG_IGN); " if ignore_terminate else "") + + f"pathlib.Path({str(marker)!r}).touch(); time.sleep(60)" + ) + + async def launch(*_args, **kwargs): + proc = await spawn(sys.executable, "-c", script, **kwargs) + captured.append(proc) + started.set() + return proc + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + monkeypatch.setattr(archive_subprocess, "_PROCESS_TERMINATE_TIMEOUT_SECONDS", 0.05) + task = asyncio.create_task(_run_archive_operation("convert", {}, cleanup_paths=[partial])) + try: + await asyncio.wait_for(started.wait(), 5) + async with asyncio.timeout(5): + while not marker.exists(): + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert captured[0].returncode is not None, "cancel returned while child was still alive" + assert not partial.exists() + finally: + if captured and captured[0].returncode is None: + captured[0].kill() + await captured[0].wait() + + +async def test_cancellation_during_spawn_still_reaps_acquired_worker(tmp_path, monkeypatch): + acquired = asyncio.Event() + release = asyncio.Event() + captured = [] + spawn = asyncio.create_subprocess_exec + + async def launch(*_args, **kwargs): + proc = await spawn(sys.executable, "-c", "import time; time.sleep(60)", **kwargs) + captured.append(proc) + acquired.set() + await release.wait() + return proc + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + task = asyncio.create_task(_run_archive_operation("convert", {})) + try: + await asyncio.wait_for(acquired.wait(), 5) + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert captured[0].returncode is not None, "spawn cancellation orphaned the worker" + finally: + release.set() + if captured and captured[0].returncode is None: + captured[0].kill() + await captured[0].wait() + + +async def test_second_cancel_cannot_interrupt_worker_reaping(tmp_path, monkeypatch): + ready = asyncio.Event() + terminating = asyncio.Event() + done = asyncio.Event() + + class Process: + returncode = None + + async def communicate(self): + ready.set() + await done.wait() + return b"{}", b"" + + def terminate(self): + terminating.set() + + def kill(self): + self.returncode = -9 + done.set() + + proc = Process() + + async def launch(*_args, **_kwargs): + return proc + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + monkeypatch.setattr(archive_subprocess, "_PROCESS_TERMINATE_TIMEOUT_SECONDS", 0.05) + task = asyncio.create_task(_run_archive_operation("convert", {})) + await asyncio.wait_for(ready.wait(), 5) + task.cancel() + await asyncio.wait_for(terminating.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert proc.returncode == -9 + assert done.is_set() + + +@pytest.mark.parametrize("control", ["task", "cancel", "pause", "failure"]) +async def test_interrupted_move_preserves_destination_after_source_was_moved( + tmp_path, monkeypatch, control +): + source = tmp_path / "source.cbz" + target = tmp_path / "destination.cbz" + source.write_bytes(b"only copy") + moved = asyncio.Event() + done = asyncio.Event() + + class Process: + returncode = None + + async def communicate(self): + source.rename(target) + moved.set() + if control == "failure": + self.returncode = 1 + return b"", b'{"type":"RuntimeError","message":"worker failed after move"}' + await done.wait() + return b"{}", b"" + + def terminate(self): + self.returncode = -15 + done.set() + + async def launch(*_args, **_kwargs): + return Process() + + async def check(): + if control == "cancel": + raise JobCancelledError("stop") + if control == "pause": + raise JobPausedError("stop") + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + monkeypatch.setattr(archive_subprocess, "_CONTROL_POLL_INTERVAL_SECONDS", 0.01) + task = asyncio.create_task( + transfer_file_interruptible(source, target, "move", cancellation_check=check) + ) + await asyncio.wait_for(moved.wait(), 5) + if control == "task": + task.cancel() + expected = { + "task": asyncio.CancelledError, + "cancel": JobCancelledError, + "pause": JobPausedError, + "failure": RuntimeError, + }[control] + with pytest.raises(expected): + await task + assert target.exists(), "cleanup deleted the sole surviving copy after the move" + assert target.read_bytes() == b"only copy" + assert not source.exists() + + def test_progress_state_cleanup_and_worker_error_branches(tmp_path: Path) -> None: missing = tmp_path / "missing.json" invalid = tmp_path / "invalid.json" From cea6ed60ad457e6cadabbaa17b33e48a892acfc3 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 01:38:36 -0700 Subject: [PATCH 65/97] feat(metadata): bind archive writes to current library ownership --- docs/development/ARCHITECTURE_OVERVIEW.md | 15 + .../services/archive_metadata_binding.py | 283 ++++++++++ .../services/metadata_series_refresh_state.py | 27 +- src/pullbox/services/metadata_sources.py | 9 +- .../test_archive_metadata_binding.py | 509 ++++++++++++++++++ 5 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 src/pullbox/services/archive_metadata_binding.py create mode 100644 tests/integration/metadata_identity/test_archive_metadata_binding.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index ae552f52..525f5d4e 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -540,6 +540,21 @@ Legacy operations still publish inside their workers; they have not migrated to the staged owner-publication contract. Production binding, journal recovery and consumer migration remain required. +`services/archive_metadata_binding.py` captures one registered managed CBZ's +issue/parent, verified identities and revisions, current values/credits, canonical +baselines, source policies and root policy. It reuses the series refresh mapper +with a bounded exact issue selection rather than loading the whole catalog. +Legacy ComicVine columns must agree with active ownership; stale or conflicted +claims do not disappear from the check. Local XML is assembled only after this +independent binding and cannot acquire identity ownership. Database reads do not +commit or flush pending caller edits. Filesystem probes run separately, off the +event loop, and capture a writable regular file plus its directory identities +inside the recorded root. Final revalidation checks both database state and +canonical-path reference ownership; the owner separately rechecks filesystem +evidence immediately before publication. These read sets are not locks or a +durable journal. Production migration still requires a serialized recoverable +publication boundary, output baseline persistence and crash reconciliation. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/src/pullbox/services/archive_metadata_binding.py b/src/pullbox/services/archive_metadata_binding.py new file mode 100644 index 00000000..b45d5330 --- /dev/null +++ b/src/pullbox/services/archive_metadata_binding.py @@ -0,0 +1,283 @@ +"""Independent database and filesystem binding for coordinated archive writes.""" + +import asyncio +import os +import stat +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import NotFoundError +from pullbox.core.library_file_ownership import ( + ReferencedFileMutationError, + require_mutable_library_target, +) +from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind +from pullbox.core.metadata_identity_state import IdentityVerificationState +from pullbox.models import Issue, LibraryFile, LibraryRoot +from pullbox.models.library import FileFormat, LibraryFileStorageMode +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_series_refresh_state import ( + RefreshEntityState, + SeriesRefreshState, + read_series_refresh_state, +) + +type FileFingerprint = tuple[int, int, int, int, int, int] +type DirectoryFingerprint = tuple[Path, int, int, int] + + +class ArchiveMetadataBindingError(ValueError): + """A file cannot safely use the captured canonical metadata.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +@dataclass(frozen=True) +class ArchiveMetadataBinding: + """Immutable read set, not a lease, publication journal or filesystem lock.""" + + library_file_id: int + library_root_id: int + root_path: str + file_path: str + file_size: int + file_modified_at: datetime + metadata: SeriesRefreshState + + +async def read_archive_metadata_binding( + session: AsyncSession, + library_file_id: int, + *, + expected_issue_id: int | None = None, +) -> ArchiveMetadataBinding: + """Read one managed CBZ and its exact parent without provider or file I/O. + + Use a short clean reader session, then release it before inspecting/staging + archives. The invoking workflow must revalidate this read set within its + journal/serialization boundary; a successful read does not authorize a later + blind replacement. Reference-only and unverified legacy bindings fail closed. + """ + if session.new or session.dirty or session.deleted: + raise ArchiveMetadataBindingError("pending_session_changes") + if any( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + for value in ( + library_file_id, + *((expected_issue_id,) if expected_issue_id is not None else ()), + ) + ): + raise ArchiveMetadataBindingError("invalid_target") + row = ( + await session.execute( + select(LibraryFile, LibraryRoot, Issue.series_id) + .join(LibraryRoot, LibraryRoot.id == LibraryFile.library_root_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .where(LibraryFile.id == library_file_id) + .execution_options(populate_existing=True) + ) + ).one_or_none() + if row is None: + raise ArchiveMetadataBindingError("target_missing") + file, root, series_id = row + if file.storage_mode is not LibraryFileStorageMode.MANAGED: + raise ArchiveMetadataBindingError("reference_only") + if not root.enabled or not root.allow_managed_writes: + raise ArchiveMetadataBindingError("root_not_managed") + if file.file_format is not FileFormat.CBZ: + raise ArchiveMetadataBindingError("unsupported_format") + if file.issue_id is None or ( + expected_issue_id is not None and expected_issue_id != file.issue_id + ): + raise ArchiveMetadataBindingError("issue_changed") + try: + metadata = await read_series_refresh_state(session, series_id, issue_ids=(file.issue_id,)) + for entity in (metadata.series, metadata.issues[0]): + _require_verified(entity) + if not {ref.namespace for ref in metadata.issues[0].identities} <= { + ref.namespace for ref in metadata.series.identities + }: + raise ArchiveMetadataBindingError("parent_identity_missing") + # Validate baseline ownership even before reading untrusted archive input. + _assemble(metadata, None, datetime.now(UTC)) + except (ValueError, NotFoundError) as exc: + if isinstance(exc, ArchiveMetadataBindingError): + raise + raise ArchiveMetadataBindingError("metadata_requires_review") from None + return ArchiveMetadataBinding( + file.id, + root.id, + root.path, + file.file_path, + file.file_size, + file.file_modified_at, + metadata, + ) + + +def _require_verified(entity: RefreshEntityState) -> None: + if not entity.claims or any( + state is not IdentityVerificationState.VERIFIED for _, state, _ in entity.claims + ): + raise ArchiveMetadataBindingError("identity_requires_review") + comicvine = next( + (ref for ref in entity.identities if ref.namespace is IdentityNamespace.COMICVINE), None + ) + if (comicvine is not None and entity.comicvine_id != int(comicvine.external_id)) or ( + entity.comicvine_id is not None and comicvine is None + ): + raise ArchiveMetadataBindingError("identity_requires_review") + + +async def revalidate_archive_metadata_binding( + session: AsyncSession, binding: ArchiveMetadataBinding +) -> None: + """Reject stale targets, ownership, policies, baselines and user values.""" + current = await read_archive_metadata_binding( + session, binding.library_file_id, expected_issue_id=binding.metadata.issues[0].local_id + ) + if current != binding: + raise ArchiveMetadataBindingError("binding_changed") + + +@dataclass(frozen=True) +class ArchiveMetadataTarget: + binding: ArchiveMetadataBinding + path: Path + fingerprint: FileFingerprint + directories: tuple[DirectoryFingerprint, ...] + + def check_unchanged(self) -> None: + """Synchronous stat boundary; offload on async paths and check before publish.""" + if _inspect_target(self.binding) != self: + raise ArchiveMetadataBindingError("source_changed") + + +async def inspect_archive_metadata_target(binding: ArchiveMetadataBinding) -> ArchiveMetadataTarget: + """Inspect outside the DB session; never open archives or perform write probes.""" + return await asyncio.to_thread(_inspect_target, binding) + + +async def revalidate_archive_metadata_target( + session: AsyncSession, target: ArchiveMetadataTarget +) -> None: + """Revalidate the DB binding and canonical-path ownership before publication.""" + await revalidate_archive_metadata_binding(session, target.binding) + try: + await require_mutable_library_target( + session, target.path, include_descendants=False, operation="updated" + ) + except ReferencedFileMutationError: + raise ArchiveMetadataBindingError("reference_only") from None + + +def _inspect_target(binding: ArchiveMetadataBinding) -> ArchiveMetadataTarget: + try: + raw_root, raw_file = Path(binding.root_path), Path(binding.file_path) + if any( + not path.is_absolute() + or ".." in path.parts + or any(ord(c) < 32 or ord(c) == 127 for c in str(path)) + for path in (raw_root, raw_file) + ): + raise ArchiveMetadataBindingError("unsafe_path") + if not raw_file.is_relative_to(raw_root) or raw_file == raw_root: + raise ArchiveMetadataBindingError("outside_root") + if raw_file.suffix.casefold() not in {".cbz", ".zip"}: + raise ArchiveMetadataBindingError("unsupported_format") + root = raw_root.resolve(strict=True) + path = root / raw_file.relative_to(raw_root) + if path.resolve(strict=True) != path: + raise ArchiveMetadataBindingError("unsafe_path") + directories = [] + parent = root + for part in ("", *path.parent.relative_to(root).parts): + parent = parent / part + info = parent.lstat() + if not stat.S_ISDIR(info.st_mode): + raise ArchiveMetadataBindingError("unsafe_path") + directories.append((parent, info.st_dev, info.st_ino, info.st_mode)) + info = path.lstat() + if not stat.S_ISREG(info.st_mode): + raise ArchiveMetadataBindingError("unsafe_path") + if ( + not info.st_mode & 0o222 + or not directories[-1][3] & 0o222 + or not os.access(path, os.R_OK | os.W_OK) + or not os.access(path.parent, os.W_OK | os.X_OK) + ): + raise ArchiveMetadataBindingError("readonly_source") + if ( + info.st_size != binding.file_size + or datetime.fromtimestamp(info.st_mtime, UTC) != binding.file_modified_at + ): + raise ArchiveMetadataBindingError("source_changed") + return ArchiveMetadataTarget( + binding, + path, + ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_mode, + ), + tuple(directories), + ) + except (OSError, RuntimeError, ValueError) as exc: + if isinstance(exc, ArchiveMetadataBindingError): + raise + raise ArchiveMetadataBindingError("source_unavailable") from None + + +def assemble_bound_archive_metadata( + binding: ArchiveMetadataBinding, + archive: ArchiveMetadataReconciliation, + *, + now: datetime, +) -> tuple[MetadataSnapshot, MetadataSnapshot]: + """Combine DB values and local evidence without granting embedded IDs ownership.""" + try: + return _assemble(binding.metadata, archive, now) + except ValueError: + raise ArchiveMetadataBindingError("metadata_requires_review") from None + + +def _assemble( + metadata: SeriesRefreshState, archive: ArchiveMetadataReconciliation | None, now: datetime +) -> tuple[MetadataSnapshot, MetadataSnapshot]: + series, issue = metadata.series, metadata.issues[0] + return ( + assemble_metadata( + MetadataEntityKind.SERIES, + series.identities, + (), + metadata.policies, + now=now, + current=series.values, + previous=series.baseline, + overrides=series.overrides, + archive=archive, + ), + assemble_metadata( + MetadataEntityKind.ISSUE, + issue.identities, + (), + metadata.policies, + now=now, + current=issue.values, + previous=issue.baseline, + overrides=issue.overrides, + parent_identities=series.identities, + archive=archive, + ), + ) diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index 0b2fc233..c56286d5 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -98,7 +98,24 @@ def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot ) from exc -async def read_series_refresh_state(session: AsyncSession, series_id: int) -> SeriesRefreshState: +async def read_series_refresh_state( + session: AsyncSession, series_id: int, *, issue_ids: tuple[int, ...] | None = None +) -> SeriesRefreshState: + """Capture a catalog or a bounded exact subset using the same value mapping.""" + if issue_ids is not None and ( + not issue_ids + or len(issue_ids) > 200 + or len(set(issue_ids)) != len(issue_ids) + or any( + isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in issue_ids + ) + ): + raise ValueError("Choose between one and 200 distinct positive issue IDs.") + issue_filter = ( + Issue.series_id == series_id + if issue_ids is None + else (Issue.series_id == series_id) & Issue.id.in_(issue_ids) + ) row = ( await session.execute( select( @@ -164,7 +181,7 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se await session.execute( select(Issue, IssueMetadataBaseline.revision, IssueMetadataBaseline.snapshot_json) .outerjoin(IssueMetadataBaseline, IssueMetadataBaseline.issue_id == Issue.id) - .where(Issue.series_id == series_id) + .where(issue_filter) .order_by(Issue.id) .limit(10001) .execution_options(populate_existing=True) @@ -172,11 +189,13 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se ).all() if len(members) > 10000: raise ValueError("This series exceeds the bounded refresh limit of 10,000 issues.") + if issue_ids is not None and {issue.id for issue, _, _ in members} != set(issue_ids): + raise ValueError("Requested issues no longer belong to this series.") issue_claims: dict[int, list[tuple[ExternalIdentityRef, IdentityVerificationState, int]]] = {} for claim in await session.scalars( select(IssueExternalIdentity) .join(Issue, Issue.id == IssueExternalIdentity.issue_id) - .where(Issue.series_id == series_id) + .where(issue_filter) .order_by(IssueExternalIdentity.identity_namespace) .execution_options(populate_existing=True) ): @@ -194,7 +213,7 @@ async def read_series_refresh_state(session: AsyncSession, series_id: int) -> Se await session.execute( select(IssueIdentityEvent.issue_id, func.max(IssueIdentityEvent.id)) .join(Issue, Issue.id == IssueIdentityEvent.issue_id) - .where(Issue.series_id == series_id) + .where(issue_filter) .group_by(IssueIdentityEvent.issue_id) ) ) diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py index 8a02fd48..32f1eb8f 100644 --- a/src/pullbox/services/metadata_sources.py +++ b/src/pullbox/services/metadata_sources.py @@ -62,8 +62,13 @@ def default_policy(source: MetadataSource) -> SourcePolicyRead: async def read_source_policies(session: AsyncSession) -> list[SourcePolicyRead]: - rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))} - cv_row = await session.get(SystemConfig, "comicvine_api_key") + rows = { + row.source: row + for row in await session.scalars( + select(MetadataSourceConfig).execution_options(populate_existing=True) + ) + } + cv_row = await session.get(SystemConfig, "comicvine_api_key", populate_existing=True) cv_configured = bool((cv_row and cv_row.value) or get_settings().comicvine_api_key) result = [] for source in MetadataSource: diff --git a/tests/integration/metadata_identity/test_archive_metadata_binding.py b/tests/integration/metadata_identity/test_archive_metadata_binding.py new file mode 100644 index 00000000..ca3f591d --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_metadata_binding.py @@ -0,0 +1,509 @@ +"""One real registered file, not folder names or embedded IDs, binds output.""" + +import asyncio +import os +from datetime import UTC, datetime +from zipfile import ZipFile + +import pytest +from sqlalchemy import delete, event, select, update + +from pullbox.core.archive_metadata import read_archive_metadata +from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_identity import IdentityNamespace as Namespace +from pullbox.core.metadata_identity import MetadataEntityKind as Kind +from pullbox.core.metadata_identity_state import IdentityVerificationState as State +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.library import FileFormat, LibraryFileStorageMode +from pullbox.models.metadata_baseline import IssueMetadataBaseline +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, MetadataValues +from pullbox.schemas.metadata_sources import MetadataDomain +from pullbox.services.archive_metadata_binding import ( + ArchiveMetadataBindingError, + assemble_bound_archive_metadata, + inspect_archive_metadata_target, + read_archive_metadata_binding, + revalidate_archive_metadata_binding, + revalidate_archive_metadata_target, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.metadata_series_refresh_state import read_series_refresh_state +from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible + +NOW = datetime(2026, 9, 29, tzinfo=UTC) +PARENT = ExternalIdentityRef(Namespace.METRON, Kind.SERIES, "12") +ISSUE = ExternalIdentityRef(Namespace.METRON, Kind.ISSUE, "42") + + +async def seed(factory, tmp_path): + root_path = tmp_path / "comics" + folder = root_path / "Misleading folder name" + folder.mkdir(parents=True) + path = folder / "unrelated-name.cbz" + with ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"page bytes") + archive.writestr( + "ComicInfo.xml", + "Canonical series50-x" + "My local summary", + ) + async with factory.begin() as session: + root = LibraryRoot(name="Managed", path=str(root_path)) + series = Series(title="Canonical series", sort_title="Canonical series", year_start=1992) + session.add_all([root, series]) + await session.flush() + issue = Issue(series_id=series.id, issue_number=50, issue_number_text="50-x") + session.add_all( + [ + issue, + SeriesExternalIdentity( + series_id=series.id, + identity_namespace=Namespace.METRON, + external_id="12", + verification_state=State.VERIFIED, + evidence_kind="provider_result", + ), + ] + ) + await session.flush() + session.add( + IssueExternalIdentity( + issue_id=issue.id, + identity_namespace=Namespace.METRON, + external_id="42", + verification_state=State.VERIFIED, + evidence_kind="provider_result", + ) + ) + file = LibraryFile( + file_path=str(path), + file_name=path.name, + file_size=path.stat().st_size, + file_modified_at=datetime.fromtimestamp(path.stat().st_mtime, UTC), + file_format=FileFormat.CBZ, + issue_id=issue.id, + library_root_id=root.id, + ) + session.add(file) + await session.flush() + return file.id, issue.id, series.id, root.id, path + + +async def binding(factory, file_id): + async with factory() as session: + result = await read_archive_metadata_binding(session, file_id) + assert result is not None, "A registered file needs an independent metadata binding" + return result + + +async def test_binding_uses_registered_issue_not_filename_and_assembles_local_values( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + file_id, issue_id, series_id, root_id, path = await seed(factory, tmp_path) + before = path.read_bytes(), path.stat() + saved = await binding(factory, file_id) + assert saved.library_file_id == file_id + assert saved.library_root_id == root_id + assert saved.metadata.series.local_id == series_id + assert tuple(item.local_id for item in saved.metadata.issues) == (issue_id,) + assert saved.metadata.series.identities == (PARENT,) + assert saved.metadata.issues[0].identities == (ISSUE,) + target = await inspect_archive_metadata_target(saved) + assert target.path == path.resolve() + target.check_unchanged() + archive = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1024) + ) + series, issue = assemble_bound_archive_metadata(saved, archive, now=NOW) + assert series.values.title == "Canonical series" + assert series.values.year_start == 1992 and series.values.volume is None + assert issue.values.issue_number_text == "50-X" + assert issue.values.description == "My local summary" + assert next( + origin for origin in issue.origins if origin.field == "description" + ).embedded_documents == ("ComicInfo.xml",) + async with factory() as session: + await revalidate_archive_metadata_binding(session, saved) + assert path.read_bytes() == before[0] + assert (path.stat().st_ino, path.stat().st_mtime_ns, path.stat().st_mode) == ( + before[1].st_ino, + before[1].st_mtime_ns, + before[1].st_mode, + ) + + +@pytest.mark.parametrize( + "change", + [ + "missing_file", + "unmatched", + "wrong_issue", + "referenced", + "disabled_root", + "reference_root", + "stale_issue", + "conflicted_series", + "missing_issue_identity", + "missing_parent_identity", + "legacy_issue_drift", + "legacy_parent_drift", + "unsupported", + "invalid_baseline", + ], +) +async def test_untrusted_binding_is_rejected_before_file_work(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + file_id, issue_id, _, _, _ = await seed(factory, tmp_path) + async with factory.begin() as session: + statements = { + "missing_file": delete(LibraryFile).where(LibraryFile.id == file_id), + "unmatched": update(LibraryFile).values(issue_id=None), + "referenced": update(LibraryFile).values( + storage_mode=LibraryFileStorageMode.REFERENCED + ), + "disabled_root": update(LibraryRoot).values(enabled=False), + "reference_root": update(LibraryRoot).values(allow_managed_writes=False), + "stale_issue": update(IssueExternalIdentity).values(verification_state=State.STALE), + "conflicted_series": update(SeriesExternalIdentity).values( + verification_state=State.CONFLICTED + ), + "missing_issue_identity": delete(IssueExternalIdentity), + "missing_parent_identity": delete(SeriesExternalIdentity), + "legacy_issue_drift": update(Issue).values(comicvine_id=700), + "legacy_parent_drift": update(Series).values(comicvine_id=600), + "unsupported": update(LibraryFile).values(file_format=FileFormat.CBR), + } + if change in statements: + await session.execute(statements[change]) + elif change == "invalid_baseline": + session.add(IssueMetadataBaseline(issue_id=issue_id, revision=1, snapshot_json="{}")) + async with factory() as session: + with pytest.raises(ArchiveMetadataBindingError): + await read_archive_metadata_binding( + session, + file_id, + expected_issue_id=issue_id + 100 if change == "wrong_issue" else issue_id, + ) + + +@pytest.mark.parametrize( + "change", + [ + "issue_title", + "series_title", + "root_path", + "root_policy", + "storage_mode", + "file_path", + "file_size", + "file_date", + "identity_revision", + "parent_revision", + "reassigned", + "baseline", + "issue_deleted", + ], +) +async def test_new_session_changes_invalidate_staged_binding(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + file_id, issue_id, series_id, _, path = await seed(factory, tmp_path) + saved = await binding(factory, file_id) + async with factory.begin() as session: + statements = { + "issue_title": update(Issue).values(title="User edit"), + "series_title": update(Series).values(title="User series edit"), + "root_path": update(LibraryRoot).values(path=str(tmp_path)), + "root_policy": update(LibraryRoot).values(allow_managed_writes=False), + "storage_mode": update(LibraryFile).values( + storage_mode=LibraryFileStorageMode.REFERENCED + ), + "file_path": update(LibraryFile).values(file_path=str(path.with_name("another.cbz"))), + "file_size": update(LibraryFile).values(file_size=1), + "file_date": update(LibraryFile).values(file_modified_at=NOW), + "identity_revision": update(IssueExternalIdentity).values(revision=2), + "parent_revision": update(SeriesExternalIdentity).values(revision=2), + "issue_deleted": delete(Issue).where(Issue.id == issue_id), + } + if change in statements: + await session.execute(statements[change]) + elif change == "reassigned": + other = Issue(series_id=series_id, issue_number=51) + session.add(other) + await session.flush() + await session.execute(update(LibraryFile).values(issue_id=other.id)) + else: + snapshot = MetadataSnapshot( + entity_kind=Kind.ISSUE, identities=(ISSUE,), values=MetadataValues(title="Earlier") + ) + session.add( + IssueMetadataBaseline( + issue_id=issue_id, revision=1, snapshot_json=snapshot.model_dump_json() + ) + ) + async with factory() as session: + with pytest.raises(ArchiveMetadataBindingError): + await revalidate_archive_metadata_binding(session, saved) + + +@pytest.mark.parametrize( + "change", + [ + "readonly", + "missing", + "symlink", + "outside", + "directory", + "traversal", + "size", + "mtime", + "readonly_parent", + ], +) +async def test_source_policy_and_filesystem_are_independently_checked( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + file_id, _, _, _, path = await seed(factory, tmp_path) + try: + if change == "readonly": + path.chmod(0o444) + elif change == "readonly_parent": + path.parent.chmod(0o555) + elif change == "missing": + path.unlink() + elif change == "symlink": + other = tmp_path / "elsewhere.cbz" + path.rename(other) + path.symlink_to(other) + elif change == "directory": + path.unlink() + path.mkdir() + elif change == "size": + path.write_bytes(b"replaced") + elif change == "mtime": + os.utime(path, (1, 1)) + elif change in {"outside", "traversal"}: + async with factory.begin() as session: + await session.execute( + update(LibraryFile).values( + file_path=str(tmp_path / "elsewhere.cbz") + if change == "outside" + else str(path.parent / ".." / path.parent.name / path.name) + ) + ) + saved = await binding(factory, file_id) + with pytest.raises(ArchiveMetadataBindingError): + await inspect_archive_metadata_target(saved) + finally: + path.parent.chmod(0o755) + + +@pytest.mark.parametrize("change", ["file", "parent", "mode"]) +async def test_inspected_target_cannot_bless_replaced_paths(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + file_id, _, _, _, path = await seed(factory, tmp_path) + target = await inspect_archive_metadata_target(await binding(factory, file_id)) + assert target is not None + if change == "file": + original = path.read_bytes() + before = path.stat() + path.unlink() + path.write_bytes(original) + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + elif change == "parent": + old = path.parent.with_name("old") + path.parent.rename(old) + path.parent.symlink_to(old, target_is_directory=True) + else: + path.chmod(0o444) + with pytest.raises(ArchiveMetadataBindingError): + target.check_unchanged() + + +async def test_targeted_binding_does_not_load_unrelated_catalog_or_commit( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + file_id, _, series_id, _, _ = await seed(factory, tmp_path) + async with factory.begin() as session: + other = Issue(series_id=series_id, issue_number=99) + session.add(other) + await session.flush() + session.add(IssueMetadataBaseline(issue_id=other.id, revision=1, snapshot_json="{}")) + statements = [] + + def capture(_connection, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", capture) + try: + saved = await binding(factory, file_id) + assert len(saved.metadata.issues) == 1 + assert len(statements) < 20 + assert not any( + sql.lstrip().upper().startswith(("UPDATE", "INSERT", "DELETE")) for sql in statements + ) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", capture) + async with factory() as session: + series = await session.get(Series, series_id) + series.title = "Unflushed user change" + with pytest.raises(ArchiveMetadataBindingError): + await read_archive_metadata_binding(session, file_id) + assert series.title == "Unflushed user change" + await session.rollback() + async with factory() as session: + assert ( + await session.scalar(select(Series.title).where(Series.id == series_id)) + == "Canonical series" + ) + + +async def test_embedded_exact_identity_cannot_change_registered_target(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + file_id, _, _, _, path = await seed(factory, tmp_path) + saved = await binding(factory, file_id) + with ZipFile(path, "a") as archive: + archive.writestr( + "MetronInfo.xml", + '999', + ) + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1024) + ) + with pytest.raises(ArchiveMetadataBindingError): + assemble_bound_archive_metadata(saved, evidence, now=NOW) + + +@pytest.mark.parametrize("change", ["priority", "health_only"]) +async def test_revalidation_reads_current_policy_even_in_populated_session( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + file_id, _, _, _, _ = await seed(factory, tmp_path) + async with factory.begin() as session: + session.add(MetadataSourceConfig(source="metron_api", enabled=True, priority=30)) + async with factory() as session: + cached = await session.scalar(select(MetadataSourceConfig)) + saved = await read_archive_metadata_binding(session, file_id) + async with factory.begin() as writer: + await writer.execute( + update(MetadataSourceConfig).values( + **( + {"priority": 5, "revision": 2} + if change == "priority" + else {"last_tested_at": NOW} + ) + ) + ) + if change == "priority": + with pytest.raises(ArchiveMetadataBindingError): + await revalidate_archive_metadata_binding(session, saved) + else: + await revalidate_archive_metadata_binding(session, saved) + assert cached is not None + + +@pytest.mark.parametrize("current", [None, "", "My edited summary"]) +async def test_archive_does_not_refill_explicit_database_edits_or_clears( + identity_probe_db, tmp_path, current +): + _, factory, _ = identity_probe_db + file_id, issue_id, _, _, path = await seed(factory, tmp_path) + previous = MetadataSnapshot( + entity_kind=Kind.ISSUE, + identities=(ISSUE,), + values=MetadataValues(description="My local summary", issue_number_text="50-X"), + origins=( + FieldOrigin( + field="description", + domain=MetadataDomain.CORE, + observed_at=NOW, + embedded_documents=("ComicInfo.xml",), + ), + ), + ) + async with factory.begin() as session: + session.add( + IssueMetadataBaseline( + issue_id=issue_id, revision=1, snapshot_json=previous.model_dump_json() + ) + ) + await session.execute(update(Issue).values(description=current)) + saved = await binding(factory, file_id) + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1024) + ) + _, issue = assemble_bound_archive_metadata(saved, evidence, now=NOW) + assert issue.values.description == current + assert next(origin for origin in issue.origins if origin.field == "description").user_override + + +async def test_binding_and_real_paired_worker_preserve_original_until_owner_publication( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + file_id, _, _, _, path = await seed(factory, tmp_path) + original = path.read_bytes() + saved = await binding(factory, file_id) + target = await inspect_archive_metadata_target(saved) + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1024) + ) + series, issue = assemble_bound_archive_metadata(saved, evidence, now=NOW) + async with stage_cbz_metadata_interruptible( + target.path, + target.path.parent, + series, + issue, + max_uncompressed_bytes=1000000, + ) as staged: + async with factory() as session: + await revalidate_archive_metadata_target(session, target) + await asyncio.to_thread(target.check_unchanged) + staged.check_unchanged() + with ZipFile(staged.path) as archive: + assert archive.read("page.jpg") == b"page bytes" + assert b"My local summary" in archive.read("ComicInfo.xml") + assert b"My local summary" in archive.read("MetronInfo.xml") + assert path.read_bytes() == original + assert set(path.parent.iterdir()) == {path} + + +@pytest.mark.parametrize("selection", [(), (True,), (-1,), (1, 1), tuple(range(1, 202))]) +async def test_exact_subset_is_bounded_and_validated(identity_probe_db, selection): + _, factory, _ = identity_probe_db + async with factory() as session: + with pytest.raises(ValueError, match="distinct positive issue IDs"): + await read_series_refresh_state(session, 1, issue_ids=selection) + + +async def test_managed_root_alias_cannot_mutate_registered_reference(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + file_id, issue_id, _, root_id, path = await seed(factory, tmp_path) + alias = tmp_path / "alias" + alias.symlink_to(path.parent.parent, target_is_directory=True) + async with factory.begin() as session: + await session.execute(update(LibraryRoot).values(path=str(alias))) + await session.execute( + update(LibraryFile).values(file_path=str(alias / path.parent.name / path.name)) + ) + session.add( + LibraryFile( + file_path=str(path), + file_name=path.name, + file_size=path.stat().st_size, + file_modified_at=datetime.fromtimestamp(path.stat().st_mtime, UTC), + file_format=FileFormat.CBZ, + issue_id=issue_id, + library_root_id=root_id, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + ) + target = await inspect_archive_metadata_target(await binding(factory, file_id)) + async with factory() as session: + with pytest.raises(ArchiveMetadataBindingError, match="reference_only"): + await revalidate_archive_metadata_target(session, target) From e8723f8ef9642a7ad4517ff55036fbf50a713a99 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 02:43:21 -0700 Subject: [PATCH 66/97] feat(metadata): journal paired archive publication and recovery --- ...9v012_add_archive_metadata_publications.py | 59 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 16 + docs/development/DATABASE_STANDARDS.md | 19 + src/pullbox/models/__init__.py | 2 + .../models/archive_metadata_publication.py | 51 ++ .../services/archive_metadata_publication.py | 449 ++++++++++++++ .../test_archive_metadata_publication.py | 586 ++++++++++++++++++ tests/integration/test_migrations.py | 3 +- .../unit/test_archive_publication_evidence.py | 69 +++ 9 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py create mode 100644 src/pullbox/models/archive_metadata_publication.py create mode 100644 src/pullbox/services/archive_metadata_publication.py create mode 100644 tests/integration/metadata_identity/test_archive_metadata_publication.py create mode 100644 tests/unit/test_archive_publication_evidence.py diff --git a/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py b/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py new file mode 100644 index 00000000..3ad8648e --- /dev/null +++ b/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py @@ -0,0 +1,59 @@ +"""Retain paired archive publication intent across process and DB failures.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "y6s7t8u9v012" +down_revision = "x5r6s7t8u901" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "archive_metadata_publications", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("operation_id", sa.String(36), nullable=False, unique=True), + sa.Column( + "library_file_id", sa.Integer(), sa.ForeignKey("library_files.id", ondelete="SET NULL") + ), + sa.Column( + "active_file_id", + sa.Integer(), + sa.ForeignKey("library_files.id", ondelete="SET NULL"), + unique=True, + ), + sa.Column("active_path_key", sa.String(64), unique=True), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("state", sa.String(9), nullable=False), + sa.Column("plan_json", sa.Text(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.CheckConstraint("revision > 0", name="ck_archive_publication_revision"), + sa.CheckConstraint( + "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size" + ), + sa.CheckConstraint( + "state IN ('intended','published','abandoned','review')", + name="archive_publication_state", + ), + sa.CheckConstraint( + "(state = 'abandoned' AND active_file_id IS NULL AND active_path_key IS NULL) OR " + "(state <> 'abandoned' AND active_path_key IS NOT NULL)", + name="ck_archive_publication_reservation", + ), + ) + + +def downgrade() -> None: + table = sa.table("archive_metadata_publications", sa.column("state")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned") + ): + raise RuntimeError("Resolve retained archive publication work before downgrading.") + op.drop_table("archive_metadata_publications") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 525f5d4e..2cedb76b 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -555,6 +555,22 @@ evidence immediately before publication. These read sets are not locks or a durable journal. Production migration still requires a serialized recoverable publication boundary, output baseline persistence and crash reconciliation. +`services/archive_metadata_publication.py` now retains bounded publication intent +before replacement. Independent file/path reservations do not expire, and deleting +a library row does not erase recovery evidence. Preparation validates the staged +XML against its canonical snapshots and hashes source/output outside DB sessions. +Publication rechecks binding under policy/parent/issue/root/file locks and holds +the journal lock through the short offloaded stat/rename/directory-sync boundary. +Cancellation joins file work before releasing the lock. Recovery hashes outside +the transaction, then rechecks file evidence and journal revision under that same +lock. It recognizes the verified staged inode/content after a rename/DB-commit +failure, abandons only a proven untouched original, and retains unknown outcomes +for review. Recovery never republishes or deletes a file. The published state is +filesystem evidence only, not completed canonical DB adoption; its reservation +stays active. Production callers remain unchanged pending finalization, rollback, +shared mutation coordination and restart orchestration. Existing legacy writers +and external filesystem programs are not fenced by this new journal. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 65ceb107..92345b08 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -487,6 +487,25 @@ persist across refreshes until a new archive comparison updates them. The caller still owns independent file/target matching and atomic application to entity rows; baseline persistence alone neither establishes that match nor permits file writes. +### Archive Publication Intent + +`archive_metadata_publications` stores versioned, bounded evidence before a paired +archive replacement. A unique operation ID supports exact replay; active file and +canonical-path reservations reject concurrent attempts. Nullable `SET NULL` file +links preserve the immutable intent when the library row is deleted; the path +reservation and evidence survive. There is no age-based lease takeover. + +Services use caller-owned transactions and the shared SQLite write/savepoint +boundary. Publication locks source policies, series, issue, root, file, then the +journal row. Recovery only locks the journal row. Large file hashing, rendering +and staging stay outside write transactions; only the short, offloaded atomic +publication and directory-sync boundary retains the lock. An intent must commit +before publication. A failed post-rename transaction leaves evidence for later +inspection instead of authorizing blind replacement. Published records remain +reserved pending coherent canonical/file-state finalization. Downgrade refuses +to discard any intended, published or review record; only abandoned-only history +may be removed. No existing archive is backfilled or mutated by the migration. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index 70dd1247..dd28ec55 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -5,6 +5,7 @@ """ from pullbox.models.airdcpp import AirDcppAcquisition, AirDcppClientSettings +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication from pullbox.models.audit_log import AuditEventType, AuditLog from pullbox.models.base import Base, IdentityMixin, TimestampMixin from pullbox.models.blocklist import BlocklistEntry, BlocklistReason @@ -132,6 +133,7 @@ "APIKey", "AirDcppAcquisition", "AirDcppClientSettings", + "ArchiveMetadataPublication", "AuditEventType", "AuditLog", "Base", diff --git a/src/pullbox/models/archive_metadata_publication.py b/src/pullbox/models/archive_metadata_publication.py new file mode 100644 index 00000000..dd25b5ac --- /dev/null +++ b/src/pullbox/models/archive_metadata_publication.py @@ -0,0 +1,51 @@ +"""Retained publication intent, independent of the lifetime of a library row.""" + +from enum import StrEnum + +from sqlalchemy import CheckConstraint, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin + + +class PublicationState(StrEnum): + INTENDED = "intended" + PUBLISHED = "published" + ABANDONED = "abandoned" + REVIEW = "review" + + +class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin): + __tablename__ = "archive_metadata_publications" + __table_args__ = ( + CheckConstraint("revision > 0", name="ck_archive_publication_revision"), + CheckConstraint( + "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size" + ), + CheckConstraint( + "(state = 'abandoned' AND active_file_id IS NULL AND active_path_key IS NULL) OR " + "(state <> 'abandoned' AND active_path_key IS NOT NULL)", + name="ck_archive_publication_reservation", + ), + ) + + operation_id: Mapped[str] = mapped_column(String(36), unique=True) + library_file_id: Mapped[int | None] = mapped_column( + ForeignKey("library_files.id", ondelete="SET NULL") + ) + active_file_id: Mapped[int | None] = mapped_column( + ForeignKey("library_files.id", ondelete="SET NULL"), unique=True + ) + active_path_key: Mapped[str | None] = mapped_column(String(64), unique=True) + revision: Mapped[int] = mapped_column(Integer, default=1) + state: Mapped[PublicationState] = mapped_column( + Enum( + PublicationState, + native_enum=False, + create_constraint=True, + values_callable=lambda enum: [item.value for item in enum], + name="archive_publication_state", + ), + default=PublicationState.INTENDED, + ) + plan_json: Mapped[str] = mapped_column(Text) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py new file mode 100644 index 00000000..e9e3694f --- /dev/null +++ b/src/pullbox/services/archive_metadata_publication.py @@ -0,0 +1,449 @@ +"""Durable intent and serialized publication; canonical finalization stays separate.""" + +import asyncio +import hashlib +import os +import stat +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.archive_metadata import read_archive_metadata +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_binding import ( + ArchiveMetadataTarget, + FileFingerprint, + revalidate_archive_metadata_target, +) +from pullbox.services.archive_metadata_rendering import ( + ArchiveMetadataRenderError, + render_archive_metadata, +) +from pullbox.services.metadata_writer_identity import metadata_write_scope +from pullbox.utilities.executors.archive_metadata_staging import StagedArchiveMetadata + +_MAX_PLAN_BYTES = 4 * 1024 * 1024 + + +class ArchivePublicationError(ValueError): + """Fixed publication diagnostic without paths or embedded payloads.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +class ArchivePublicationPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal[1] = 1 + target: ArchiveMetadataTarget + stage_path: Path + stage_fingerprint: FileFingerprint + source_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + output_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + series: MetadataSnapshot + issue: MetadataSnapshot + + +@dataclass(frozen=True) +class ArchivePublicationReceipt: + operation_id: UUID + revision: int + state: PublicationState + plan: ArchivePublicationPlan + + +@dataclass(frozen=True) +class ArchivePublicationInspection: + operation_id: UUID + revision: int + fingerprint: FileFingerprint | None + digest: str | None + + +def _fingerprint(path: Path) -> FileFingerprint | None: + try: + return _stat_fingerprint(path.lstat()) + except FileNotFoundError: + return None + + +def _stat_fingerprint(info: os.stat_result) -> FileFingerprint: + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_mode, + ) + + +def _check_cancelled(stop: Event) -> None: + if stop.is_set(): + raise ArchivePublicationError("cancelled") + + +async def _file_work[T](work: Callable[[Event], T]) -> T: + stop = Event() + task = asyncio.create_task(asyncio.to_thread(work, stop)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + stop.set() + while not task.done(): + with suppress(asyncio.CancelledError, OSError, ValueError): + await asyncio.shield(task) + if not task.cancelled(): + task.exception() + raise + + +def _digest(path: Path, expected: FileFingerprint, stop: Event) -> str: + _check_cancelled(stop) + if not stat.S_ISREG(expected[5]): + raise ArchivePublicationError("file_changed") + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as stream: + if _stat_fingerprint(os.fstat(stream.fileno())) != expected: + raise ArchivePublicationError("file_changed") + digest = hashlib.sha256() + remaining = expected[2] + while remaining: + _check_cancelled(stop) + chunk = stream.read(min(1024 * 1024, remaining)) + if not chunk: + raise ArchivePublicationError("file_changed") + remaining -= len(chunk) + digest.update(chunk) + if ( + stream.read(1) + or _stat_fingerprint(os.fstat(stream.fileno())) != expected + or _fingerprint(path) != expected + ): + raise ArchivePublicationError("file_changed") + return digest.hexdigest() + + +async def prepare_archive_publication( + target: ArchiveMetadataTarget, + staged: StagedArchiveMetadata, + series: MetadataSnapshot, + issue: MetadataSnapshot, +) -> ArchivePublicationPlan: + """Hash verified evidence outside DB sessions; change only the private stage mode.""" + return await _file_work(lambda stop: _prepare(target, staged, series, issue, stop)) + + +def _prepare( + target: ArchiveMetadataTarget, + staged: StagedArchiveMetadata, + series: MetadataSnapshot, + issue: MetadataSnapshot, + stop: Event, +) -> ArchivePublicationPlan: + _check_cancelled(stop) + bound = target.binding.metadata + if ( + series.entity_kind is not MetadataEntityKind.SERIES + or issue.entity_kind is not MetadataEntityKind.ISSUE + or series.identities != bound.series.identities + or issue.identities != bound.issues[0].identities + or staged.source_path != target.path + or staged.source_fingerprint != target.fingerprint + or staged.path.parent.parent != target.path.parent + or not staged.path.parent.name.startswith(".pullbox-metadata-stage-") + or staged.path.name != "metadata.cbz" + or staged.path.resolve(strict=True) != staged.path + or staged.output_fingerprint[0] != target.fingerprint[0] + ): + raise ArchivePublicationError("invalid_plan") + target.check_unchanged() + staged.check_unchanged() + files = read_archive_metadata(staged.path, "cbz", max_solid_scan_bytes=_MAX_PLAN_BYTES) + try: + pair = render_archive_metadata(series, issue, files) + if pair.comicinfo != files.comicinfo.payload or pair.metroninfo != files.metroninfo.payload: + raise ArchivePublicationError("snapshot_disagrees") + except ArchiveMetadataRenderError: + raise ArchivePublicationError("snapshot_disagrees") from None + _check_cancelled(stop) + fd = os.open(staged.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as stream: + if _stat_fingerprint(os.fstat(stream.fileno())) != staged.output_fingerprint: + raise ArchivePublicationError("file_changed") + os.fchmod(stream.fileno(), stat.S_IMODE(target.fingerprint[5]) & 0o777) + output = _stat_fingerprint(os.fstat(stream.fileno())) + os.fsync(stream.fileno()) + plan = ArchivePublicationPlan( + target=target, + stage_path=staged.path, + stage_fingerprint=output, + source_digest=_digest(target.path, target.fingerprint, stop), + output_digest=_digest(staged.path, output, stop), + series=series, + issue=issue, + ) + _encode(plan) + return plan + + +def _encode(plan: ArchivePublicationPlan) -> str: + encoded = plan.model_dump_json() + if len(encoded.encode("utf-8")) > _MAX_PLAN_BYTES: + raise ArchivePublicationError("plan_too_large") + if ArchivePublicationPlan.model_validate_json(encoded) != plan: + raise ArchivePublicationError("invalid_plan") + return encoded + + +def _receipt(row: ArchiveMetadataPublication) -> ArchivePublicationReceipt: + try: + if len(row.plan_json.encode("utf-8")) > _MAX_PLAN_BYTES: + raise ValueError("Too large") + plan = ArchivePublicationPlan.model_validate_json(row.plan_json) + return ArchivePublicationReceipt(UUID(row.operation_id), row.revision, row.state, plan) + except ValueError: + raise ArchivePublicationError("invalid_journal") from None + + +def _clean_session(session: AsyncSession) -> None: + if session.new or session.dirty or session.deleted: + raise ArchivePublicationError("pending_session_changes") + + +async def load_archive_publication( + session: AsyncSession, operation_id: UUID +) -> ArchivePublicationReceipt | None: + _clean_session(session) + row = await session.scalar( + select(ArchiveMetadataPublication) + .where(ArchiveMetadataPublication.operation_id == str(operation_id)) + .execution_options(populate_existing=True) + ) + return _receipt(row) if row else None + + +async def _lock_binding(session: AsyncSession, plan: ArchivePublicationPlan) -> None: + bound = plan.target.binding + await session.execute( + select(MetadataSourceConfig.id) + .order_by(MetadataSourceConfig.source) + .with_for_update(read=True) + ) + for model, local_id in ( + (Series, bound.metadata.series.local_id), + (Issue, bound.metadata.issues[0].local_id), + (LibraryRoot, bound.library_root_id), + (LibraryFile, bound.library_file_id), + ): + await session.execute(select(model.id).where(model.id == local_id).with_for_update()) + + +async def record_archive_publication( + session: AsyncSession, plan: ArchivePublicationPlan, operation_id: UUID +) -> ArchivePublicationReceipt: + """Caller must commit this intent before any filesystem publication. + + Reservations do not expire. Recovery needs the same row lock as publication; + elapsed time is never permission to let another writer take over. + """ + _clean_session(session) + encoded = _encode(plan) + path_key = hashlib.sha256(os.fsencode(plan.target.path)).hexdigest() + try: + async with metadata_write_scope(session): + await _lock_binding(session, plan) + existing = await load_archive_publication(session, operation_id) + if existing: + if existing.plan != plan: + raise ArchivePublicationError("operation_reused") + return existing + busy = await session.scalar( + select(ArchiveMetadataPublication.id) + .where( + or_( + ArchiveMetadataPublication.active_file_id + == plan.target.binding.library_file_id, + ArchiveMetadataPublication.active_path_key == path_key, + ) + ) + .limit(1) + ) + if busy: + raise ArchivePublicationError("publication_busy") + await revalidate_archive_metadata_target(session, plan.target) + row = ArchiveMetadataPublication( + operation_id=str(operation_id), + library_file_id=plan.target.binding.library_file_id, + active_file_id=plan.target.binding.library_file_id, + active_path_key=path_key, + plan_json=encoded, + ) + session.add(row) + await session.flush() + session.info[f"archive_intent:{operation_id}"] = session.sync_session.get_transaction() + return _receipt(row) + except IntegrityError: + raise ArchivePublicationError("publication_busy") from None + + +async def _locked_row(session: AsyncSession, operation_id: UUID) -> ArchiveMetadataPublication: + row = await session.scalar( + select(ArchiveMetadataPublication) + .where(ArchiveMetadataPublication.operation_id == str(operation_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None: + raise ArchivePublicationError("publication_missing") + return row + + +def _directories_unchanged(plan: ArchivePublicationPlan) -> None: + for path, device, inode, mode in plan.target.directories: + info = path.lstat() + if (info.st_dev, info.st_ino, info.st_mode) != (device, inode, mode): + raise ArchivePublicationError("directories_changed") + + +async def publish_archive_publication( + session: AsyncSession, operation_id: UUID +) -> ArchivePublicationReceipt: + """Replace under a short DB write lock, using an already committed intent. + + Only stat/rename/directory-sync operations occur in the short offloaded file + boundary, never archive reads or hashes. Cancellation joins it before releasing + the DB lock. Commit failure leaves intent for evidence-based recovery. The + reservation remains until canonical DB finalization is implemented. Existing + writers must not switch to this primitive before that integration. + """ + _clean_session(session) + if ( + session.info.get(f"archive_intent:{operation_id}") is session.sync_session.get_transaction() + and session.in_transaction() + ): + raise ArchivePublicationError("intent_not_committed") + async with metadata_write_scope(session): + receipt = await load_archive_publication(session, operation_id) + if receipt is None: + raise ArchivePublicationError("publication_missing") + await _lock_binding(session, receipt.plan) + row = await _locked_row(session, operation_id) + if row.state is not PublicationState.INTENDED: + raise ArchivePublicationError("publication_not_intended") + plan = _receipt(row).plan + await revalidate_archive_metadata_target(session, plan.target) + await _file_work(lambda stop: _publish_files(plan, stop)) + row.state = PublicationState.PUBLISHED + row.revision += 1 + await session.flush() + return _receipt(row) + + +def _publish_files(plan: ArchivePublicationPlan, stop: Event) -> None: + plan.target.check_unchanged() + if ( + plan.stage_path.resolve(strict=True) != plan.stage_path + or _fingerprint(plan.stage_path) != plan.stage_fingerprint + ): + raise ArchivePublicationError("file_changed") + _check_cancelled(stop) + os.replace(plan.stage_path, plan.target.path) + # Persist the destination directory entry before reporting publication. A + # failed sync leaves intent for inspection, never permission to publish again. + fd = os.open(plan.target.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(fd) + finally: + os.close(fd) + + +async def inspect_archive_publication( + receipt: ArchivePublicationReceipt, +) -> ArchivePublicationInspection: + """Read content outside the DB write boundary; this never repairs or deletes files.""" + return await _file_work(lambda stop: _inspect(receipt, stop)) + + +def _inspect(receipt: ArchivePublicationReceipt, stop: Event) -> ArchivePublicationInspection: + _check_cancelled(stop) + plan = receipt.plan + fingerprint = _fingerprint(plan.target.path) + digest = None + if ( + fingerprint is not None + and stat.S_ISREG(fingerprint[5]) + and fingerprint[2] in {plan.target.fingerprint[2], plan.stage_fingerprint[2]} + ): + _directories_unchanged(plan) + digest = _digest(plan.target.path, fingerprint, stop) + return ArchivePublicationInspection(receipt.operation_id, receipt.revision, fingerprint, digest) + + +def _published(plan: ArchivePublicationPlan, inspection: ArchivePublicationInspection) -> bool: + actual, expected = inspection.fingerprint, plan.stage_fingerprint + # Rename may change ctime. Identity, mode, size, mtime and actual bytes must agree. + return bool( + actual + and actual[:4] == expected[:4] + and actual[5] == expected[5] + and inspection.digest == plan.output_digest + ) + + +async def reconcile_archive_publication( + session: AsyncSession, + receipt: ArchivePublicationReceipt, + inspection: ArchivePublicationInspection, +) -> ArchivePublicationReceipt: + """Fence the original publisher, then classify the inspected durable result. + + Published means only filesystem publication, not canonical DB finalization. + Unknown content remains reserved for review; only a proven untouched original + can release an intended reservation. No recovery path republishes a stage. + """ + _clean_session(session) + async with metadata_write_scope(session): + row = await _locked_row(session, receipt.operation_id) + if _receipt(row) != receipt or (inspection.operation_id, inspection.revision) != ( + receipt.operation_id, + receipt.revision, + ): + raise ArchivePublicationError("publication_changed") + _directories_unchanged(receipt.plan) + if _fingerprint(receipt.plan.target.path) != inspection.fingerprint: + raise ArchivePublicationError("inspection_changed") + if row.state in {PublicationState.ABANDONED, PublicationState.REVIEW}: + return receipt + state = PublicationState.REVIEW + if _published(receipt.plan, inspection): + state = PublicationState.PUBLISHED + elif ( + row.state is PublicationState.INTENDED + and inspection.fingerprint == receipt.plan.target.fingerprint + and inspection.digest == receipt.plan.source_digest + ): + state = PublicationState.ABANDONED + if row.state is not state: + row.state = state + row.revision += 1 + if state is PublicationState.ABANDONED: + row.active_file_id = None + row.active_path_key = None + await session.flush() + return _receipt(row) diff --git a/tests/integration/metadata_identity/test_archive_metadata_publication.py b/tests/integration/metadata_identity/test_archive_metadata_publication.py new file mode 100644 index 00000000..242d2f16 --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_metadata_publication.py @@ -0,0 +1,586 @@ +"""Durable intent survives the filesystem/transaction crash window.""" + +import asyncio +import importlib.util +import os +import threading +from contextlib import asynccontextmanager +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 +from zipfile import ZipFile + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import Column, Integer, MetaData, Table, delete, inspect, update + +from pullbox.core.archive_metadata import read_archive_metadata +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.library import FileFormat +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.services.archive_metadata_binding import ( + assemble_bound_archive_metadata, + inspect_archive_metadata_target, + read_archive_metadata_binding, +) +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + PublicationState, + inspect_archive_publication, + load_archive_publication, + prepare_archive_publication, + publish_archive_publication, + reconcile_archive_publication, + record_archive_publication, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible + + +@asynccontextmanager +async def prepared(factory, tmp_path): + root_path = tmp_path / "comics" + root_path.mkdir() + path = root_path / "example.cbz" + with ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"page bytes") + archive.writestr("ComicInfo.xml", "50-X") + path.chmod(0o640) + async with factory.begin() as session: + root = LibraryRoot(name="Managed", path=str(root_path)) + series = Series(title="Example", sort_title="Example", year_start=1992) + session.add_all([root, series]) + await session.flush() + issue = Issue(series_id=series.id, issue_number=50, issue_number_text="50-X") + session.add(issue) + await session.flush() + session.add_all( + [ + SeriesExternalIdentity( + series_id=series.id, + identity_namespace="metron", + external_id="12", + verification_state="verified", + evidence_kind="provider_result", + ), + IssueExternalIdentity( + issue_id=issue.id, + identity_namespace="metron", + external_id="42", + verification_state="verified", + evidence_kind="provider_result", + ), + ] + ) + file = LibraryFile( + file_path=str(path), + file_name=path.name, + file_size=path.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.fromtimestamp(path.stat().st_mtime, UTC), + issue_id=issue.id, + library_root_id=root.id, + ) + session.add(file) + await session.flush() + file_id = file.id + async with factory() as session: + bound = await read_archive_metadata_binding(session, file_id) + target = await inspect_archive_metadata_target(bound) + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=10000) + ) + series, issue = assemble_bound_archive_metadata(bound, evidence, now=datetime.now(UTC)) + async with stage_cbz_metadata_interruptible( + path, root_path, series, issue, max_uncompressed_bytes=1000000 + ) as staged: + plan = await prepare_archive_publication(target, staged, series, issue) + assert plan is not None, "A verified stage needs durable pre-publication evidence" + yield path, staged, plan + + +async def record(factory, plan, operation=None): + operation = operation or uuid4() + async with factory.begin() as session: + saved = await record_archive_publication(session, plan, operation) + assert saved is not None, "Publication intent must persist before replacement" + return saved + + +async def load(factory, operation): + async with factory() as session: + result = await load_archive_publication(session, operation) + assert result is not None + return result + + +async def test_intent_round_trip_is_durable_and_does_not_publish(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, staged, plan): + original = path.read_bytes() + saved = await record(factory, plan) + current = await load(factory, saved.operation_id) + assert current == saved + assert current.state is PublicationState.INTENDED + assert current.plan == plan + assert current.revision == 1 + assert path.read_bytes() == original and staged.path.exists() + assert await record(factory, plan, saved.operation_id) == saved + + +async def test_record_is_caller_owned_and_rolls_back(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + operation = uuid4() + async with factory() as session: + assert await record_archive_publication(session, plan, operation) is not None + await session.rollback() + async with factory() as session: + assert await load_archive_publication(session, operation) is None + + +async def test_only_one_intent_can_reserve_a_file(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + + async def attempt(): + try: + return await record(factory, plan) + except ArchivePublicationError as exc: + return exc.code + + results = await asyncio.gather(attempt(), attempt()) + assert sum(result == "publication_busy" for result in results) == 1 + assert sum(result != "publication_busy" for result in results) == 1 + + +async def test_publication_preserves_mode_and_remains_reserved_for_db_finalization( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, staged, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + published = await publish_archive_publication(session, saved.operation_id) + assert published.state is PublicationState.PUBLISHED + assert published.revision == 2 + assert path.stat().st_mode & 0o777 == 0o640 + assert not staged.path.exists() + with ZipFile(path) as archive: + assert archive.read("page.jpg") == b"page bytes" + assert "MetronInfo.xml" in archive.namelist() + async with factory() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_size == plan.target.binding.file_size + with pytest.raises(ArchivePublicationError, match="publication_busy"): + await record(factory, plan) + + +async def test_crash_after_rename_before_commit_recovers_without_republishing( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + saved = await record(factory, plan) + async with factory() as session: + assert ( + await publish_archive_publication(session, saved.operation_id) + ).state is PublicationState.PUBLISHED + await session.rollback() # Model process loss after atomic replacement. + before = path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns + current = await load(factory, saved.operation_id) + assert current.state is PublicationState.INTENDED + inspection = await inspect_archive_publication(current) + async with factory.begin() as session: + recovered = await reconcile_archive_publication(session, current, inspection) + assert recovered.state is PublicationState.PUBLISHED + assert (path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) == before + inspection = await inspect_archive_publication(recovered) + async with factory.begin() as session: + assert await reconcile_archive_publication(session, recovered, inspection) == recovered + + +async def test_crash_before_replacement_abandons_without_changing_files( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, staged, plan): + original, output = path.read_bytes(), staged.path.read_bytes() + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + async with factory.begin() as session: + abandoned = await reconcile_archive_publication(session, saved, inspection) + assert abandoned.state is PublicationState.ABANDONED + assert path.read_bytes() == original and staged.path.read_bytes() == output + with pytest.raises(ArchivePublicationError, match="publication_not_intended"): + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + assert (await record(factory, plan)).state is PublicationState.INTENDED + + +@pytest.mark.parametrize("change", ["content", "missing", "symlink", "same_bytes_new_inode"]) +async def test_recovery_never_claims_an_unproven_destination(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, staged, plan): + saved = await record(factory, plan) + if change == "content": + path.write_bytes(b"changed") + elif change == "missing": + path.unlink() + elif change == "symlink": + path.unlink() + path.symlink_to(staged.path) + else: + replacement = tmp_path / "copy.cbz" + replacement.write_bytes(path.read_bytes()) + os.replace(replacement, path) + inspection = await inspect_archive_publication(saved) + async with factory.begin() as session: + reviewed = await reconcile_archive_publication(session, saved, inspection) + assert reviewed.state is PublicationState.REVIEW + with pytest.raises(ArchivePublicationError, match="publication_busy"): + await record(factory, plan) + + +@pytest.mark.parametrize( + "change", ["disabled_root", "user_edit", "deleted_file", "changed_stage", "changed_source"] +) +async def test_stale_publication_never_replaces_source(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, staged, plan): + saved = await record(factory, plan) + if change == "changed_stage": + staged.path.write_bytes(b"new stage") + elif change == "changed_source": + path.write_bytes(b"new source") + else: + async with factory.begin() as session: + await session.execute( + { + "disabled_root": update(LibraryRoot).values(allow_managed_writes=False), + "user_edit": update(Issue).values(title="My edit"), + "deleted_file": delete(LibraryFile), + }[change] + ) + before = path.read_bytes() + with pytest.raises(ValueError): + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + assert path.read_bytes() == before + assert (await load(factory, saved.operation_id)).state is PublicationState.INTENDED + + +async def test_recovery_inspection_is_rechecked_under_the_database_lock( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + path.write_bytes(b"edited after inspection") + with pytest.raises(ArchivePublicationError, match="inspection_changed"): + async with factory.begin() as session: + await reconcile_archive_publication(session, saved, inspection) + assert (await load(factory, saved.operation_id)).state is PublicationState.INTENDED + + +async def test_recovery_rejects_stale_journal_revision(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + with pytest.raises(ArchivePublicationError, match="publication_changed"): + async with factory.begin() as session: + await reconcile_archive_publication(session, saved, inspection) + + +async def test_deleted_file_does_not_delete_recovery_evidence(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + await session.execute(delete(LibraryFile)) + assert (await load(factory, saved.operation_id)).plan == plan + + +async def test_publication_requires_committed_intent(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + before = path.read_bytes() + operation = uuid4() + async with factory.begin() as session: + await record_archive_publication(session, plan, operation) + with pytest.raises(ArchivePublicationError, match="intent_not_committed"): + await publish_archive_publication(session, operation) + assert path.read_bytes() == before + + +async def test_journal_retains_the_snapshot_actually_rendered_in_the_stage( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, stage, plan): + stage = replace(stage, output_fingerprint=plan.stage_fingerprint) + wrong_issue = plan.issue.model_copy( + update={"values": plan.issue.values.model_copy(update={"title": "Never rendered"})} + ) + with pytest.raises(ArchivePublicationError, match="snapshot_disagrees"): + await prepare_archive_publication(plan.target, stage, plan.series, wrong_issue) + + +async def test_reused_operation_cannot_change_its_plan(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + altered = plan.model_copy(update={"output_digest": "0" * 64}) + with pytest.raises(ArchivePublicationError, match="operation_reused"): + await record(factory, altered, saved.operation_id) + assert await load(factory, saved.operation_id) == saved + + +async def test_failed_replace_retains_the_original_and_the_intent( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + saved = await record(factory, plan) + before = path.read_bytes() + + def fail(*args): + raise OSError("test rename failure") + + monkeypatch.setattr("pullbox.services.archive_metadata_publication.os.replace", fail) + with pytest.raises(OSError, match="test rename failure"): + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + assert path.read_bytes() == before + assert await load(factory, saved.operation_id) == saved + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py" + ) + spec = importlib.util.spec_from_file_location("archive_publication_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_migration_round_trip_matches_model_without_backfill(identity_probe_db): + engine, _, _ = identity_probe_db + table = ArchiveMetadataPublication.__table__ + async with engine.begin() as connection: + await connection.run_sync(table.drop) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + + def compare(conn): + expected = MetaData() + Table("library_files", expected, Column("id", Integer, primary_key=True)) + table.to_metadata(expected) + context = MigrationContext.configure( + conn, + opts={ + "include_object": lambda obj, name, type_, reflected, compare_to: ( + name == table.name if type_ == "table" else True + ), + "compare_server_default": True, + }, + ) + return compare_metadata(context, expected) + + assert await connection.run_sync(compare) == [] + assert not (await connection.execute(table.select())).all() + await connection.run_sync(lambda conn: revision(conn).downgrade()) + assert table.name not in await connection.run_sync( + lambda conn: inspect(conn).get_table_names() + ) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + + +@pytest.mark.parametrize("state", ["intended", "published", "review"]) +async def test_downgrade_never_discards_unresolved_journal(identity_probe_db, tmp_path, state): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + await session.execute(update(ArchiveMetadataPublication).values(state=state)) + with pytest.raises(RuntimeError, match="Resolve retained archive publication"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + assert (await load(factory, saved.operation_id)).plan == plan + + +async def test_corrupt_journal_fails_closed_without_mutating_files(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + saved = await record(factory, plan) + before = path.read_bytes() + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values(plan_json='{"schema_version": 900}') + ) + with pytest.raises(ArchivePublicationError, match="invalid_journal"): + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + assert path.read_bytes() == before + + +async def test_concurrent_publishers_replace_exactly_once(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + + async def publish(): + try: + async with factory.begin() as session: + return await publish_archive_publication(session, saved.operation_id) + except ArchivePublicationError as exc: + return exc.code + + results = await asyncio.gather(publish(), publish()) + assert sum(result == "publication_not_intended" for result in results) == 1 + assert (await load(factory, saved.operation_id)).state is PublicationState.PUBLISHED + + +async def test_recovery_and_publisher_use_the_same_serialization_boundary( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + + async def action(recover): + try: + async with factory.begin() as session: + if recover: + return await reconcile_archive_publication(session, saved, inspection) + return await publish_archive_publication(session, saved.operation_id) + except ArchivePublicationError as exc: + return exc.code + + results = await asyncio.gather(action(True), action(False)) + assert sum(isinstance(result, str) for result in results) == 1 + assert (await load(factory, saved.operation_id)).state in { + PublicationState.PUBLISHED, + PublicationState.ABANDONED, + } + + +async def test_cancel_after_replace_leaves_recoverable_intent( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + replaced = asyncio.Event() + + async def publish(): + async with factory.begin() as session: + original_flush = session.flush + + async def cancel_boundary(*args, **kwargs): + if any( + isinstance(row, ArchiveMetadataPublication) + and row.state is PublicationState.PUBLISHED + for row in session.dirty + ): + replaced.set() + await asyncio.Event().wait() + await original_flush(*args, **kwargs) + + monkeypatch.setattr(session, "flush", cancel_boundary) + await publish_archive_publication(session, saved.operation_id) + + task = asyncio.create_task(publish()) + await asyncio.wait_for(replaced.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + receipt = await load(factory, saved.operation_id) + assert receipt.state is PublicationState.INTENDED + inspection = await inspect_archive_publication(receipt) + async with factory.begin() as session: + assert ( + await reconcile_archive_publication(session, receipt, inspection) + ).state is PublicationState.PUBLISHED + + +async def test_slow_rename_does_not_block_the_event_loop(identity_probe_db, tmp_path, monkeypatch): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + started, release, timed_out = threading.Event(), threading.Event(), threading.Event() + real_replace = os.replace + + def slow_replace(*args): + started.set() + if not release.wait(1): + timed_out.set() + real_replace(*args) + + monkeypatch.setattr( + "pullbox.services.archive_metadata_publication.os.replace", slow_replace + ) + + async def publish(): + async with factory.begin() as session: + return await publish_archive_publication(session, saved.operation_id) + + task = asyncio.create_task(publish()) + try: + assert await asyncio.to_thread(started.wait, 2) + assert not timed_out.is_set(), "Filesystem publication blocked the event loop" + finally: + release.set() + await task + + +@pytest.mark.parametrize("action", ["read", "record", "publish", "recover"]) +async def test_journal_operations_do_not_flush_pending_library_edits( + identity_probe_db, tmp_path, action +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + async with factory() as session: + row = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + row.title = "Uncommitted user edit" + with pytest.raises(ArchivePublicationError, match="pending_session_changes"): + if action == "read": + await load_archive_publication(session, saved.operation_id) + elif action == "record": + await record_archive_publication(session, plan, uuid4()) + elif action == "publish": + await publish_archive_publication(session, saved.operation_id) + else: + await reconcile_archive_publication(session, saved, inspection) + assert row in session.dirty + await session.rollback() + + +async def test_abandoned_only_journal_can_be_downgraded_without_touching_library( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + saved = await record(factory, plan) + inspection = await inspect_archive_publication(saved) + async with factory.begin() as session: + await reconcile_archive_publication(session, saved, inspection) + before = path.read_bytes() + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + async with factory() as session: + assert await session.get(LibraryFile, plan.target.binding.library_file_id) + assert path.read_bytes() == before diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index ad006d3c..8bbb67a2 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["x5r6s7t8u901"] + assert script.get_heads() == ["y6s7t8u9v012"] + assert script.get_revision("y6s7t8u9v012").down_revision == "x5r6s7t8u901" assert script.get_revision("x5r6s7t8u901").down_revision == "w4q5r6s7t890" assert script.get_revision("w4q5r6s7t890").down_revision == "v3p4q5r6s789" assert script.get_revision("v3p4q5r6s789").down_revision == "u2o3p4q5r678" diff --git a/tests/unit/test_archive_publication_evidence.py b/tests/unit/test_archive_publication_evidence.py new file mode 100644 index 00000000..5872110a --- /dev/null +++ b/tests/unit/test_archive_publication_evidence.py @@ -0,0 +1,69 @@ +"""Evidence work must stop before the owning stage can be cleaned up.""" + +import asyncio +import threading + +import pytest + +from pullbox.services import archive_metadata_publication as publication + + +async def test_cancelled_preparation_reaps_file_work_before_return(monkeypatch): + started, stopped, release = threading.Event(), threading.Event(), threading.Event() + + def work(*args): + started.set() + stop = args[-1] + try: + if isinstance(stop, threading.Event): + stop.wait(2) + release.wait(2) + else: + release.wait(2) + finally: + stopped.set() + + monkeypatch.setattr(publication, "_prepare", work) + task = asyncio.create_task(publication.prepare_archive_publication(None, None, None, None)) + try: + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + await asyncio.sleep(0.02) + assert not task.done(), "Cancellation must join file work before stage cleanup" + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped.is_set() + finally: + release.set() + await asyncio.to_thread(stopped.wait, 2) + + +async def test_cancelled_inspection_reaps_file_work_before_return(monkeypatch): + started, stopped, release = threading.Event(), threading.Event(), threading.Event() + + def work(*args): + started.set() + try: + stop = args[-1] + if isinstance(stop, threading.Event): + stop.wait(2) + release.wait(2) + finally: + stopped.set() + + monkeypatch.setattr(publication, "_inspect", work) + task = asyncio.create_task(publication.inspect_archive_publication(None)) + try: + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + await asyncio.sleep(0.02) + assert not task.done(), "Inspection must not outlive its owning workflow" + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped.is_set() + finally: + release.set() + await asyncio.to_thread(stopped.wait, 2) From cd5aa9dd591c0676805e72ccc0043631eafaf27a Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 03:27:24 -0700 Subject: [PATCH 67/97] feat(metadata): finalize canonical archive publication state --- ...8u9v0w123_finalize_archive_publications.py | 37 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 13 + docs/development/DATABASE_STANDARDS.md | 14 + src/pullbox/core/archive_metadata_fields.py | 25 ++ .../models/archive_metadata_publication.py | 6 +- .../services/archive_metadata_finalization.py | 125 ++++++ .../services/archive_metadata_publication.py | 6 +- .../archive_metadata_reconciliation.py | 9 +- .../services/archive_metadata_rendering.py | 22 +- .../services/metadata_entity_values.py | 36 ++ .../services/metadata_issue_catalog.py | 6 +- .../services/metadata_series_refresh.py | 22 +- .../test_archive_finalization_migration.py | 78 ++++ .../test_archive_metadata_finalization.py | 420 ++++++++++++++++++ .../test_archive_metadata_publication.py | 27 +- tests/integration/test_migrations.py | 3 +- tests/unit/test_archive_metadata_assembly.py | 42 ++ 17 files changed, 842 insertions(+), 49 deletions(-) create mode 100644 alembic/versions/z7t8u9v0w123_finalize_archive_publications.py create mode 100644 src/pullbox/services/archive_metadata_finalization.py create mode 100644 src/pullbox/services/metadata_entity_values.py create mode 100644 tests/integration/metadata_identity/test_archive_finalization_migration.py create mode 100644 tests/integration/metadata_identity/test_archive_metadata_finalization.py diff --git a/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py b/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py new file mode 100644 index 00000000..7c5222c7 --- /dev/null +++ b/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py @@ -0,0 +1,37 @@ +"""Retain completed canonical publication receipts without active reservations.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "z7t8u9v0w123" +down_revision = "y6s7t8u9v012" +branch_labels = None +depends_on = None + + +def _constraints(*, finalized: bool) -> None: + terminal = "'abandoned','finalized'" if finalized else "'abandoned'" + states = "'intended','published','abandoned','review'" + (",'finalized'" if finalized else "") + with op.batch_alter_table("archive_metadata_publications") as batch: + batch.drop_constraint("archive_publication_state", type_="check") + batch.drop_constraint("ck_archive_publication_reservation", type_="check") + batch.create_check_constraint("archive_publication_state", f"state IN ({states})") + batch.create_check_constraint( + "ck_archive_publication_reservation", + f"(state IN ({terminal}) AND active_file_id IS NULL AND active_path_key IS NULL) OR " + f"(state NOT IN ({terminal}) AND active_path_key IS NOT NULL)", + ) + + +def upgrade() -> None: + _constraints(finalized=True) + + +def downgrade() -> None: + table = sa.table("archive_metadata_publications", sa.column("state")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.state == "finalized") + ): + raise RuntimeError("Cannot downgrade completed archive publication evidence.") + _constraints(finalized=False) diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 2cedb76b..0a7d706f 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -571,6 +571,19 @@ stays active. Production callers remain unchanged pending finalization, rollback shared mutation coordination and restart orchestration. Existing legacy writers and external filesystem programs are not fenced by this new journal. +`services/archive_metadata_finalization.py` completes a proven publication in a +caller-owned transaction. It revalidates identity, policy, user values, baseline +revisions and output evidence before applying one canonical snapshot to entity +values, credits, baselines and file state. The shared descriptive projection is +also used by catalog refresh; finalization never fabricates a catalog refresh or +changes import naming/source evidence. Completed receipts release reservations, +survive file deletion and replay without changing later edits. Failure or +cancellation retains recoverable publication state. Known writer format labels +normalize through a shared reversible mapping when read back; unknown formats and +real disagreements remain intact. File timestamps retain the same stat conversion +used by registration. Production owner rollback, shared mutation coordination, +restart orchestration and no-op archive-write avoidance remain required. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 92345b08..a07c54cd 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -506,6 +506,20 @@ reserved pending coherent canonical/file-state finalization. Downgrade refuses to discard any intended, published or review record; only abandoned-only history may be removed. No existing archive is backfilled or mutated by the migration. +Finalization uses the same policy/parent/issue/root/file/journal lock order and +requires a published receipt, independently inspected output and an unchanged +database binding. Canonical entity values, descriptive credits, baselines and +LibraryFile size/timestamp/hash commit together with the finalized marker and +reservation release. Unchanged snapshots do not advance their baselines. File +checks run off-thread before and after DB writes; finalization does not rewrite +archives. Naming/source ownership evidence and catalog lifecycle timestamps are +not repurposed. A completed replay reports historical completion without +reapplying values or claiming present ownership. Finalized receipts survive file +deletion and block downgrade to the old state contract. Stale, interrupted or +failed finalization retains the published reservation; caught errors still roll +back the nested write. Production workflow rollback and restart integration remain +required before existing writers switch to this boundary. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/core/archive_metadata_fields.py b/src/pullbox/core/archive_metadata_fields.py index 6ecff8b2..617925d1 100644 --- a/src/pullbox/core/archive_metadata_fields.py +++ b/src/pullbox/core/archive_metadata_fields.py @@ -13,6 +13,31 @@ "Translator": "translator", } +ARCHIVE_FORMATS = { + "standard": "Single Issue", + "tpb": "Trade Paperback", + "one_shot": "One-Shot", + "annual": "Annual", + "hardcover": "Hardcover", + "omnibus": "Omnibus", + "graphic_novel": "Graphic Novel", +} +COMICINFO_ONLY_FORMATS = frozenset({"special", "compendium", "deluxe", "volume"}) + + +def archive_format_label(value: str | None) -> str | None: + if value in COMICINFO_ONLY_FORMATS: + return value.title() + return ARCHIVE_FORMATS.get(value, value) if value is not None else None + + +def canonical_archive_format(value: str) -> str: + """Normalize only the exact reversible labels our writer emits.""" + for canonical in (*ARCHIVE_FORMATS, *COMICINFO_ONLY_FORMATS): + if archive_format_label(canonical) == value: + return canonical + return value + def comicinfo_credits(credits: tuple[MetadataCredit, ...]) -> tuple[MetadataCredit, ...]: """Project supported roles, without losing rich roles in the canonical record.""" diff --git a/src/pullbox/models/archive_metadata_publication.py b/src/pullbox/models/archive_metadata_publication.py index dd25b5ac..ae742af0 100644 --- a/src/pullbox/models/archive_metadata_publication.py +++ b/src/pullbox/models/archive_metadata_publication.py @@ -13,6 +13,7 @@ class PublicationState(StrEnum): PUBLISHED = "published" ABANDONED = "abandoned" REVIEW = "review" + FINALIZED = "finalized" class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin): @@ -23,8 +24,9 @@ class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin): "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size" ), CheckConstraint( - "(state = 'abandoned' AND active_file_id IS NULL AND active_path_key IS NULL) OR " - "(state <> 'abandoned' AND active_path_key IS NOT NULL)", + "(state IN ('abandoned','finalized') AND active_file_id IS NULL AND " + "active_path_key IS NULL) OR " + "(state NOT IN ('abandoned','finalized') AND active_path_key IS NOT NULL)", name="ck_archive_publication_reservation", ), ) diff --git a/src/pullbox/services/archive_metadata_finalization.py b/src/pullbox/services/archive_metadata_finalization.py new file mode 100644 index 00000000..db27a859 --- /dev/null +++ b/src/pullbox/services/archive_metadata_finalization.py @@ -0,0 +1,125 @@ +"""Finalize proven paired publication in the caller's database transaction.""" + +from datetime import UTC, datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models import Issue, LibraryFile, Series +from pullbox.models.archive_metadata_publication import PublicationState +from pullbox.services.archive_metadata_binding import revalidate_archive_metadata_target +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + ArchivePublicationInspection, + ArchivePublicationReceipt, + _clean_session, + _directories_unchanged, + _file_work, + _lock_binding, + _locked_row, + _published, + _receipt, + _stat_fingerprint, + load_archive_publication, +) +from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_credits import write_issue_credits +from pullbox.services.metadata_entity_values import ( + apply_issue_metadata_values, + apply_series_metadata_values, +) +from pullbox.services.metadata_writer_identity import metadata_write_scope + + +def _check_output( + receipt: ArchivePublicationReceipt, inspection: ArchivePublicationInspection +) -> datetime: + try: + _directories_unchanged(receipt.plan) + info = receipt.plan.target.path.lstat() + if _stat_fingerprint(info) != inspection.fingerprint or not _published( + receipt.plan, inspection + ): + raise ArchivePublicationError("inspection_changed") + # Match existing registration/binding conversion, not ns-to-float division + # which can round a submicrosecond boundary differently from stat.st_mtime. + return datetime.fromtimestamp(info.st_mtime, UTC) + except OSError: + raise ArchivePublicationError("inspection_changed") from None + + +async def finalize_archive_publication( + session: AsyncSession, + receipt: ArchivePublicationReceipt, + inspection: ArchivePublicationInspection, +) -> ArchivePublicationReceipt: + """Adopt only an independently inspected, still-current publication. + + Hashing belongs to inspection outside this transaction. Short file rechecks + are offloaded under the journal lock. A completed replay is historical proof, + not permission to reapply metadata or proof of current filesystem ownership. + Workflow rollback journals and legacy writers still need owner integration. + """ + _clean_session(session) + if (inspection.operation_id, inspection.revision) != (receipt.operation_id, receipt.revision): + raise ArchivePublicationError("publication_changed") + async with metadata_write_scope(session): + known = await load_archive_publication(session, receipt.operation_id) + if known is None: + raise ArchivePublicationError("publication_missing") + if known.plan != receipt.plan: + raise ArchivePublicationError("publication_changed") + await _lock_binding(session, known.plan) + row = await _locked_row(session, receipt.operation_id) + current = _receipt(row) + if current.state is PublicationState.FINALIZED and ( + current == receipt + or ( + receipt.state is PublicationState.PUBLISHED + and current.revision == receipt.revision + 1 + and current.plan == receipt.plan + ) + ): + return current + if current != receipt: + raise ArchivePublicationError("publication_changed") + if current.state is not PublicationState.PUBLISHED: + raise ArchivePublicationError("publication_not_published") + plan = receipt.plan + await revalidate_archive_metadata_target(session, plan.target) + modified_at = await _file_work(lambda stop: _check_output(receipt, inspection)) + bound = plan.target.binding.metadata + if ( + not plan.series.values.title + or not plan.series.values.sort_title + or plan.series.values.issue_count != bound.series.values.issue_count + or plan.issue.values.issue_number_text != bound.issues[0].values.issue_number_text + ): + raise ArchivePublicationError("invalid_plan") + series = await session.get(Series, bound.series.local_id) + issue = await session.get(Issue, bound.issues[0].local_id) + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert series is not None and issue is not None and file is not None + await apply_series_metadata_values(session, series, plan.series.values) + apply_issue_metadata_values(issue, plan.issue.values) + await write_issue_credits(session, {issue.id: plan.issue.values.credits}) + await save_metadata_baselines( + session, + [ + MetadataBaselineWrite(entity.local_id, snapshot, entity.baseline_revision) + for entity, snapshot in ((bound.series, plan.series), (bound.issues[0], plan.issue)) + if entity.baseline != snapshot + ], + ) + assert inspection.fingerprint is not None + file.file_size = inspection.fingerprint[2] + file.file_modified_at = modified_at + file.file_hash = plan.output_digest + file.has_comicinfo = True + await session.flush() + await _file_work(lambda stop: _check_output(receipt, inspection)) + row.state = PublicationState.FINALIZED + row.revision += 1 + row.active_file_id = None + row.active_path_key = None + await session.flush() + return _receipt(row) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index e9e3694f..e71e1597 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -329,8 +329,8 @@ async def publish_archive_publication( Only stat/rename/directory-sync operations occur in the short offloaded file boundary, never archive reads or hashes. Cancellation joins it before releasing the DB lock. Commit failure leaves intent for evidence-based recovery. The - reservation remains until canonical DB finalization is implemented. Existing - writers must not switch to this primitive before that integration. + reservation remains until separate canonical DB finalization commits. Existing + writers still require workflow ownership and rollback integration. """ _clean_session(session) if ( @@ -425,6 +425,8 @@ async def reconcile_archive_publication( receipt.revision, ): raise ArchivePublicationError("publication_changed") + if row.state is PublicationState.FINALIZED: + return receipt _directories_unchanged(receipt.plan) if _fingerprint(receipt.plan.target.path) != inspection.fingerprint: raise ArchivePublicationError("inspection_changed") diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py index c2ac69b6..55895092 100644 --- a/src/pullbox/services/archive_metadata_reconciliation.py +++ b/src/pullbox/services/archive_metadata_reconciliation.py @@ -9,7 +9,11 @@ from pydantic import ValidationError from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile -from pullbox.core.archive_metadata_fields import COMICINFO_ROLES, comicinfo_credits +from pullbox.core.archive_metadata_fields import ( + COMICINFO_ROLES, + canonical_archive_format, + comicinfo_credits, +) from pullbox.core.comicinfo_sanitizer import references_stale_retailer from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.metadata_identity import ( @@ -148,6 +152,8 @@ def _values( try: if name == "issue_number_text" and isinstance(value, str): value = normalize_issue_number_text(value) + elif name == "series_type" and isinstance(value, str): + value = canonical_archive_format(value) normalized = MetadataValues.model_validate({name: value}) except (ValidationError, ValueError): diagnostics.append(ArchiveMetadataDiagnostic(document, "invalid_field", locator)) @@ -316,6 +322,7 @@ def read(self) -> EmbeddedMetadataValues: "title": (self.read_field("Series"), "Series"), "publisher": (self.read_field("Publisher"), "Publisher"), "language": (self.read_field("LanguageISO"), "LanguageISO"), + "series_type": (self.read_field("Format"), "Format"), }, document, self.diagnostics, diff --git a/src/pullbox/services/archive_metadata_rendering.py b/src/pullbox/services/archive_metadata_rendering.py index 3f7243df..f571ce47 100644 --- a/src/pullbox/services/archive_metadata_rendering.py +++ b/src/pullbox/services/archive_metadata_rendering.py @@ -4,7 +4,11 @@ from xml.etree import ElementTree as ET from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile +from pullbox.core.archive_metadata_fields import ( + COMICINFO_ONLY_FORMATS as _COMICINFO_ONLY_FORMATS, +) from pullbox.core.archive_metadata_fields import COMICINFO_ROLES, comicinfo_credits +from pullbox.core.archive_metadata_fields import archive_format_label as _format_text from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.metadata_identity import ( ExternalIdentityRef, @@ -32,24 +36,6 @@ IdentityNamespace.METRON: "Metron", IdentityNamespace.GCD: "Grand Comics Database", } -_FORMATS = { - "standard": "Single Issue", - "tpb": "Trade Paperback", - "one_shot": "One-Shot", - "annual": "Annual", - "hardcover": "Hardcover", - "omnibus": "Omnibus", - "graphic_novel": "Graphic Novel", -} -_COMICINFO_ONLY_FORMATS = frozenset({"special", "compendium", "deluxe", "volume"}) - - -def _format_text(value: str | None) -> str | None: - if value in _COMICINFO_ONLY_FORMATS: - return value.title() - return _FORMATS.get(value, value) if value is not None else None - - _SERIES_PATHS = { "title": ("Series", "Name"), "sort_title": (None, "SortName"), diff --git a/src/pullbox/services/metadata_entity_values.py b/src/pullbox/services/metadata_entity_values.py new file mode 100644 index 00000000..5119172a --- /dev/null +++ b/src/pullbox/services/metadata_entity_values.py @@ -0,0 +1,36 @@ +"""Shared descriptive projection, separate from identity and catalog lifecycle.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models import Issue, Series +from pullbox.models.series import SeriesStatus, SeriesType +from pullbox.schemas.metadata_snapshot import MetadataValues +from pullbox.services.metadata_service import MetadataService + + +async def apply_series_metadata_values( + session: AsyncSession, series: Series, values: MetadataValues +) -> None: + series.title = values.title or series.title + series.sort_title = values.sort_title or series.sort_title + series.description, series.year_start, series.year_end = ( + values.description, + values.year_start, + values.year_end, + ) + series.cover_url = values.image_url + series.publisher_id = ( + await MetadataService._ensure_publisher(session, values.publisher) + if values.publisher + else None + ) + if values.series_type is not None: + series.series_type = SeriesType(values.series_type) + if values.status is not None: + series.status = SeriesStatus(values.status) + + +def apply_issue_metadata_values(issue: Issue, values: MetadataValues) -> None: + issue.title, issue.description = values.title, values.description + issue.release_date, issue.store_date = values.cover_date, values.store_date + issue.page_count, issue.cover_url = values.page_count, values.image_url diff --git a/src/pullbox/services/metadata_issue_catalog.py b/src/pullbox/services/metadata_issue_catalog.py index 6d340d55..bdd8618f 100644 --- a/src/pullbox/services/metadata_issue_catalog.py +++ b/src/pullbox/services/metadata_issue_catalog.py @@ -12,6 +12,7 @@ from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_credits import write_issue_credits +from pullbox.services.metadata_entity_values import apply_issue_metadata_values from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation from pullbox.services.metadata_series_adoption import ( @@ -130,10 +131,7 @@ async def apply_issue_batch( metadata_source=_metadata_label(source), ) session.add(issue) - value = snapshot.values - issue.title, issue.description = value.title, value.description - issue.release_date, issue.store_date = value.cover_date, value.store_date - issue.page_count, issue.cover_url = value.page_count, value.image_url + apply_issue_metadata_values(issue, snapshot.values) pending.append((issue, existing, metadata, snapshot)) await session.flush() await attach_verified_identities( diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index 31886793..cdda44dc 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -17,7 +17,7 @@ from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource from pullbox.models import Issue, Series from pullbox.models.metadata_source import MetadataSourceConfig -from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType +from pullbox.models.series import IssueCatalogState from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, field_domain from pullbox.schemas.metadata_sources import ( MetadataDomain, @@ -31,6 +31,7 @@ from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_entity_values import apply_series_metadata_values from pullbox.services.metadata_issue_catalog import ( IssueCatalogConflictError, SourceIssueBatch, @@ -380,24 +381,7 @@ async def _apply( ) -> SeriesCatalogRefresh: series = await session.get(Series, state.series.local_id) assert series is not None - values = snapshot.values - series.title = values.title or series.title - series.sort_title = values.sort_title or series.sort_title - series.description, series.year_start, series.year_end = ( - values.description, - values.year_start, - values.year_end, - ) - series.cover_url = values.image_url - series.publisher_id = ( - await MetadataService._ensure_publisher(session, values.publisher) - if values.publisher - else None - ) - if values.series_type is not None: - series.series_type = SeriesType(values.series_type) - if values.status is not None: - series.status = SeriesStatus(values.status) + await apply_series_metadata_values(session, series, snapshot.values) created = await apply_issue_batch( session, state, diff --git a/tests/integration/metadata_identity/test_archive_finalization_migration.py b/tests/integration/metadata_identity/test_archive_finalization_migration.py new file mode 100644 index 00000000..1181ae9a --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_finalization_migration.py @@ -0,0 +1,78 @@ +"""Publication completion extends constraints without erasing pending evidence.""" + +import importlib.util +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, select, update + +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + load, + prepared, + record, +) +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + revision as original_revision, +) + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/z7t8u9v0w123_finalize_archive_publications.py" + ) + spec = importlib.util.spec_from_file_location("archive_finalization_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_upgrade_preserves_intent_and_extends_only_completion_constraints( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + table = ArchiveMetadataPublication.__table__ + async with engine.begin() as connection: + await connection.run_sync(table.drop) + await connection.run_sync(lambda conn: original_revision(conn).upgrade()) + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).upgrade()) + constraints = await connection.run_sync( + lambda conn: inspect(conn).get_check_constraints(table.name) + ) + states = next( + item["sqltext"] + for item in constraints + if item["name"] == "archive_publication_state" + ) + assert "finalized" in states, "Upgrade must allow durable completion receipts" + assert await load(factory, saved.operation_id) == saved + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + assert await load(factory, saved.operation_id) == saved + + +async def test_downgrade_refuses_to_erase_completion_evidence(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + await record(factory, plan) + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values( + state="finalized", active_file_id=None, active_path_key=None + ) + ) + with pytest.raises(RuntimeError, match="completed archive publication"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + async with factory() as session: + assert ( + await session.scalar(select(ArchiveMetadataPublication)) + ).state.value == "finalized" diff --git a/tests/integration/metadata_identity/test_archive_metadata_finalization.py b/tests/integration/metadata_identity/test_archive_metadata_finalization.py new file mode 100644 index 00000000..0c20681f --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_metadata_finalization.py @@ -0,0 +1,420 @@ +"""Canonical values and file state commit with publication completion, never before.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, func, select, update + +from pullbox.core.archive_metadata import read_archive_metadata +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.library import LibraryFileStorageMode +from pullbox.models.metadata_identity import IssueExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.publisher import Publisher +from pullbox.services.archive_metadata_binding import ( + ArchiveMetadataBindingError, + assemble_bound_archive_metadata, + inspect_archive_metadata_target, + read_archive_metadata_binding, +) +from pullbox.services.archive_metadata_finalization import finalize_archive_publication +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + PublicationState, + inspect_archive_publication, + publish_archive_publication, + reconcile_archive_publication, + record_archive_publication, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.metadata_baselines import load_metadata_baseline +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + load, + prepared, + record, +) + +XML = """Example50-X +Local issue titleLocal summary +Local PublisherA Writer +1992431 +en2""" + + +async def published(factory, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + saved = await publish_archive_publication(session, saved.operation_id) + return saved, await inspect_archive_publication(saved) + + +async def finish(factory, receipt, inspection): + async with factory.begin() as session: + result = await finalize_archive_publication(session, receipt, inspection) + assert result is not None, "Published metadata must be finalized durably" + assert result.state.value == "finalized" + return result + + +async def test_finalization_adopts_canonical_values_and_file_evidence_together( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + binding = plan.target.binding + async with factory.begin() as session: + file = await session.get(LibraryFile, binding.library_file_id) + file.file_hash = "a" * 64 + file.source_signature = {"original": "ownership evidence"} + file.naming_snapshot = {"source": "original naming"} + saved, inspection = await published(factory, plan) + before = path.read_bytes(), path.stat() + completed = await finish(factory, saved, inspection) + assert completed.revision == saved.revision + 1 + assert (path.read_bytes(), path.stat()) == before + async with factory() as session: + current = await read_archive_metadata_binding(session, binding.library_file_id) + assert current.metadata.series.values == plan.series.values + assert current.metadata.issues[0].values == plan.issue.values + assert current.metadata.series.baseline == plan.series + assert current.metadata.issues[0].baseline == plan.issue + assert current.metadata.series.baseline_revision == 1 + assert current.metadata.issues[0].baseline_revision == 1 + file = await session.get(LibraryFile, binding.library_file_id) + assert file.file_hash == plan.output_digest + assert file.file_size == inspection.fingerprint[2] + assert file.file_modified_at == datetime.fromtimestamp(path.stat().st_mtime, UTC) + assert file.has_comicinfo + assert file.source_signature == {"original": "ownership evidence"} + assert file.naming_snapshot == {"source": "original naming"} + row = await session.scalar(select(ArchiveMetadataPublication)) + assert row.active_file_id is None and row.active_path_key is None + series = await session.get(Series, current.metadata.series.local_id) + assert series.metadata_last_refreshed is None + assert series.issue_catalog_last_synced_at is None + assert await inspect_archive_metadata_target(current) is not None + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1000000) + ) + assert assemble_bound_archive_metadata(current, evidence, now=datetime.now(UTC)) == ( + plan.series, + plan.issue, + ) + + +async def test_finalization_is_caller_owned_and_can_retry_after_db_rollback( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + before = path.read_bytes() + async with factory() as session: + assert await finalize_archive_publication(session, saved, inspection) is not None + await session.rollback() + assert await load(factory, saved.operation_id) == saved + async with factory() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_size == plan.target.binding.file_size + assert ( + await load_metadata_baseline( + session, + MetadataEntityKind.ISSUE, + plan.target.binding.metadata.issues[0].local_id, + ) + is None + ) + await finish(factory, saved, inspection) + assert path.read_bytes() == before + + +async def test_completed_replay_does_not_touch_later_user_edits_or_file_changes( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + completed = await finish(factory, saved, inspection) + async with factory.begin() as session: + await session.execute(update(Issue).values(title="Later user edit")) + path.write_bytes(b"Later replacement") + assert await finish(factory, saved, inspection) == completed + async with factory() as session: + assert await session.scalar(select(Issue.title)) == "Later user edit" + baseline = await load_metadata_baseline( + session, MetadataEntityKind.ISSUE, plan.target.binding.metadata.issues[0].local_id + ) + assert baseline.revision == 1 + async with factory.begin() as session: + assert ( + await reconcile_archive_publication( + session, completed, await inspect_archive_publication(completed) + ) + == completed + ) + assert path.read_bytes() == b"Later replacement" + + +async def test_concurrent_finalizers_apply_once(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (_, _, plan): + saved, inspection = await published(factory, plan) + results = await asyncio.gather( + finish(factory, saved, inspection), finish(factory, saved, inspection) + ) + assert results[0] == results[1] + async with factory() as session: + baseline = await load_metadata_baseline( + session, MetadataEntityKind.ISSUE, plan.target.binding.metadata.issues[0].local_id + ) + assert baseline.revision == 1 + + +@pytest.mark.parametrize( + "change", ["issue", "series", "root", "reference", "delete", "identity", "policy"] +) +async def test_stale_db_state_keeps_publication_reserved(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + async with factory.begin() as session: + if change == "issue": + await session.execute(update(Issue).values(description="New user description")) + elif change == "series": + await session.execute(update(Series).values(title="New series title")) + elif change == "root": + await session.execute(update(LibraryRoot).values(allow_managed_writes=False)) + elif change == "reference": + await session.execute( + update(LibraryFile).values(storage_mode=LibraryFileStorageMode.REFERENCED) + ) + elif change == "identity": + await session.execute(update(IssueExternalIdentity).values(external_id="43")) + elif change == "policy": + session.add(MetadataSourceConfig(source="metron_api", enabled=True, priority=3)) + else: + await session.execute(delete(LibraryFile)) + before = path.read_bytes() + async with factory.begin() as session: + with pytest.raises((ArchivePublicationError, ArchiveMetadataBindingError)): + await finalize_archive_publication(session, saved, inspection) + assert await load(factory, saved.operation_id) == saved + assert path.read_bytes() == before + async with factory() as session: + assert ( + await load_metadata_baseline( + session, + MetadataEntityKind.ISSUE, + plan.target.binding.metadata.issues[0].local_id, + ) + is None + ) + + +@pytest.mark.parametrize("change", ["file", "missing", "inspection", "revision", "plan"]) +async def test_unproven_output_cannot_finalize(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + receipt = saved + if change == "file": + path.write_bytes(b"Unexpected changed output") + elif change == "missing": + path.unlink() + elif change == "inspection": + inspection = replace(inspection, digest="a" * 64) + elif change == "revision": + receipt = replace(saved, revision=0) + else: + receipt = replace(saved, plan=plan.model_copy(update={"output_digest": "a" * 64})) + async with factory.begin() as session: + with pytest.raises(ArchivePublicationError): + await finalize_archive_publication(session, receipt, inspection) + assert await load(factory, saved.operation_id) == saved + + +async def test_intent_requires_recovery_before_finalization(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (_, _, plan): + saved = await record(factory, plan) + async with factory() as session: + await publish_archive_publication(session, saved.operation_id) + await session.rollback() + inspection = await inspect_archive_publication(saved) + async with factory.begin() as session: + with pytest.raises(ArchivePublicationError, match="publication_not_published"): + await finalize_archive_publication(session, saved, inspection) + async with factory.begin() as session: + recovered = await reconcile_archive_publication(session, saved, inspection) + await finish(factory, recovered, await inspect_archive_publication(recovered)) + + +async def test_pending_user_edits_are_not_flushed(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (_, _, plan): + saved, inspection = await published(factory, plan) + async with factory() as session: + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + issue.title = "Unrelated pending edit" + with pytest.raises(ArchivePublicationError, match="pending_session_changes"): + await finalize_archive_publication(session, saved, inspection) + assert issue in session.dirty + await session.rollback() + + +async def test_completed_reservation_allows_a_new_intent(identity_probe_db, tmp_path): + from pullbox.services.archive_metadata_publication import prepare_archive_publication + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + await finish(factory, saved, inspection) + async with factory() as session: + binding = await read_archive_metadata_binding( + session, plan.target.binding.library_file_id + ) + target = await inspect_archive_metadata_target(binding) + evidence = reconcile_archive_metadata( + read_archive_metadata(path, "cbz", max_solid_scan_bytes=1000000) + ) + series, issue = assemble_bound_archive_metadata(binding, evidence, now=datetime.now(UTC)) + async with stage_cbz_metadata_interruptible( + path, path.parent, series, issue, max_uncompressed_bytes=1000000 + ) as staged: + next_plan = await prepare_archive_publication(target, staged, series, issue) + async with factory.begin() as session: + next_intent = await record_archive_publication(session, next_plan, uuid4()) + assert next_intent.state is PublicationState.INTENDED + async with factory.begin() as session: + next_publication = await publish_archive_publication( + session, next_intent.operation_id + ) + await finish( + factory, next_publication, await inspect_archive_publication(next_publication) + ) + async with factory() as session: + current = await read_archive_metadata_binding(session, binding.library_file_id) + assert current.metadata.series.baseline_revision == 1 + assert current.metadata.issues[0].baseline_revision == 1 + + +@pytest.mark.parametrize("failure", ["baseline", "file_changed"]) +async def test_caught_late_failure_rolls_back_all_canonical_changes( + identity_probe_db, tmp_path, monkeypatch, failure +): + from pullbox.services import archive_metadata_finalization as finalizer + + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + if failure == "baseline": + original = finalizer.save_metadata_baselines + + async def fail_after_baseline(*args): + await original(*args) + raise RuntimeError("Simulated database write failure") + + monkeypatch.setattr(finalizer, "save_metadata_baselines", fail_after_baseline) + expected = RuntimeError + else: + original_check = finalizer._check_output + calls = 0 + + def change_after_db_writes(*args): + nonlocal calls + calls += 1 + if calls == 2: + path.write_bytes(b"External concurrent replacement") + return original_check(*args) + + monkeypatch.setattr(finalizer, "_check_output", change_after_db_writes) + expected = ArchivePublicationError + async with factory.begin() as session: + with pytest.raises(expected): + await finalize_archive_publication(session, saved, inspection) + assert await load(factory, saved.operation_id) == saved + async with factory() as session: + assert await session.scalar(select(Issue.title)) is None + assert await session.scalar(select(func.count()).select_from(Publisher)) == 0 + assert ( + await load_metadata_baseline( + session, + MetadataEntityKind.ISSUE, + plan.target.binding.metadata.issues[0].local_id, + ) + is None + ) + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_size == plan.target.binding.file_size + + +async def test_cancellation_before_completion_flush_is_retryable( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + saved, inspection = await published(factory, plan) + pending = asyncio.Event() + before = path.read_bytes() + + async def finalize(): + async with factory.begin() as session: + original = session.flush + + async def wait_at_completion(*args, **kwargs): + if any( + isinstance(row, ArchiveMetadataPublication) + and row.state.value == "finalized" + for row in session.dirty + ): + pending.set() + await asyncio.Event().wait() + await original(*args, **kwargs) + + monkeypatch.setattr(session, "flush", wait_at_completion) + await finalize_archive_publication(session, saved, inspection) + + task = asyncio.create_task(finalize()) + await asyncio.wait_for(pending.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await load(factory, saved.operation_id) == saved + assert path.read_bytes() == before + await finish(factory, saved, inspection) + + +async def test_completed_receipt_survives_library_file_deletion(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, comicinfo=XML) as (_, _, plan): + saved, inspection = await published(factory, plan) + completed = await finish(factory, saved, inspection) + async with factory.begin() as session: + await session.execute(delete(LibraryFile)) + assert await finish(factory, saved, inspection) == completed + + +@pytest.mark.parametrize("fraction", [499, 500, 1500, 2499]) +async def test_file_timestamp_round_trip_uses_the_binding_stat_representation( + identity_probe_db, tmp_path, fraction +): + _, factory, _ = identity_probe_db + async with prepared( + factory, tmp_path, staged_mtime_ns=1_800_000_000_000_000_000 + fraction + ) as (path, _, plan): + saved, inspection = await published(factory, plan) + await finish(factory, saved, inspection) + async with factory() as session: + binding = await read_archive_metadata_binding( + session, plan.target.binding.library_file_id + ) + assert binding.file_modified_at == datetime.fromtimestamp(path.stat().st_mtime, UTC) + assert await inspect_archive_metadata_target(binding) is not None diff --git a/tests/integration/metadata_identity/test_archive_metadata_publication.py b/tests/integration/metadata_identity/test_archive_metadata_publication.py index 242d2f16..5d2effba 100644 --- a/tests/integration/metadata_identity/test_archive_metadata_publication.py +++ b/tests/integration/metadata_identity/test_archive_metadata_publication.py @@ -42,13 +42,15 @@ @asynccontextmanager -async def prepared(factory, tmp_path): +async def prepared(factory, tmp_path, *, comicinfo=None, staged_mtime_ns=None): root_path = tmp_path / "comics" root_path.mkdir() path = root_path / "example.cbz" with ZipFile(path, "w") as archive: archive.writestr("page.jpg", b"page bytes") - archive.writestr("ComicInfo.xml", "50-X") + archive.writestr( + "ComicInfo.xml", comicinfo or "50-X" + ) path.chmod(0o640) async with factory.begin() as session: root = LibraryRoot(name="Managed", path=str(root_path)) @@ -98,6 +100,20 @@ async def prepared(factory, tmp_path): async with stage_cbz_metadata_interruptible( path, root_path, series, issue, max_uncompressed_bytes=1000000 ) as staged: + if staged_mtime_ns is not None: + os.utime(staged.path, ns=(staged_mtime_ns, staged_mtime_ns)) + info = staged.path.stat() + staged = replace( + staged, + output_fingerprint=( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_mode, + ), + ) plan = await prepare_archive_publication(target, staged, series, issue) assert plan is not None, "A verified stage needs durable pre-publication evidence" yield path, staged, plan @@ -376,11 +392,16 @@ def revision(connection): async def test_migration_round_trip_matches_model_without_backfill(identity_probe_db): + from tests.integration.metadata_identity.test_archive_finalization_migration import ( + revision as completion_revision, + ) + engine, _, _ = identity_probe_db table = ArchiveMetadataPublication.__table__ async with engine.begin() as connection: await connection.run_sync(table.drop) await connection.run_sync(lambda conn: revision(conn).upgrade()) + await connection.run_sync(lambda conn: completion_revision(conn).upgrade()) def compare(conn): expected = MetaData() @@ -399,11 +420,13 @@ def compare(conn): assert await connection.run_sync(compare) == [] assert not (await connection.execute(table.select())).all() + await connection.run_sync(lambda conn: completion_revision(conn).downgrade()) await connection.run_sync(lambda conn: revision(conn).downgrade()) assert table.name not in await connection.run_sync( lambda conn: inspect(conn).get_table_names() ) await connection.run_sync(lambda conn: revision(conn).upgrade()) + await connection.run_sync(lambda conn: completion_revision(conn).upgrade()) @pytest.mark.parametrize("state", ["intended", "published", "review"]) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 8bbb67a2..48df094c 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["y6s7t8u9v012"] + assert script.get_heads() == ["z7t8u9v0w123"] + assert script.get_revision("z7t8u9v0w123").down_revision == "y6s7t8u9v012" assert script.get_revision("y6s7t8u9v012").down_revision == "x5r6s7t8u901" assert script.get_revision("x5r6s7t8u901").down_revision == "w4q5r6s7t890" assert script.get_revision("w4q5r6s7t890").down_revision == "v3p4q5r6s789" diff --git a/tests/unit/test_archive_metadata_assembly.py b/tests/unit/test_archive_metadata_assembly.py index 74f36eab..e629573f 100644 --- a/tests/unit/test_archive_metadata_assembly.py +++ b/tests/unit/test_archive_metadata_assembly.py @@ -98,6 +98,48 @@ def test_series_archive_values_flow_through_the_same_assembler(): assert origin(result, "title").embedded_documents == ("ComicInfo.xml", "MetronInfo.xml") +@pytest.mark.parametrize( + ("canonical", "label"), + [ + ("standard", "Single Issue"), + ("tpb", "Trade Paperback"), + ("one_shot", "One-Shot"), + ("annual", "Annual"), + ("hardcover", "Hardcover"), + ("omnibus", "Omnibus"), + ("graphic_novel", "Graphic Novel"), + ("special", "Special"), + ("compendium", "Compendium"), + ("deluxe", "Deluxe"), + ("volume", "Volume"), + ], +) +def test_written_format_labels_round_trip_without_false_disagreement(canonical, label): + archive = embedded(f"{label}", None) + result = assemble_metadata( + Kind.SERIES, + (PARENT,), + [], + [], + now=NOW, + archive=archive, + current=MetadataValues(series_type=canonical), + ) + assert archive.series.series_type == canonical + assert result.values.series_type == canonical + assert not any("series_type" in reason for reason in result.diagnostics) + adopted = assemble_metadata(Kind.SERIES, (PARENT,), [], [], now=NOW, archive=archive) + assert adopted.values.series_type == canonical + assert origin(adopted, "series_type").embedded_documents == ("ComicInfo.xml",) + + +def test_unknown_formats_and_real_format_conflicts_are_not_normalized_away(): + archive = embedded("Unknown custom format", None) + assert archive.series.series_type == "Unknown custom format" + conflict = embedded("Annual", "Single Issue") + assert any(item.field == "series_type" for item in conflict.differences) + + @pytest.mark.parametrize("cleared", ["My title", "", None]) def test_edits_and_clears_survive_archive_adoption_and_subsequent_provider_refresh(cleared): before = assemble(candidates=[candidate()]) From 3eb25e47f10450997332b6c5b83c629db0741144 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 03:48:34 -0700 Subject: [PATCH 68/97] fix(metadata): reconcile ComicInfo counts on archive round-trip --- docs/development/ARCHITECTURE_OVERVIEW.md | 5 ++- .../archive_metadata_reconciliation.py | 1 + .../test_archive_metadata_finalization.py | 9 ++++- .../test_archive_metadata_reconciliation.py | 38 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 0a7d706f..169288d3 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -581,7 +581,10 @@ survive file deletion and replay without changing later edits. Failure or cancellation retains recoverable publication state. Known writer format labels normalize through a shared reversible mapping when read back; unknown formats and real disagreements remain intact. File timestamps retain the same stat conversion -used by registration. Production owner rollback, shared mutation coordination, +used by registration. ComicInfo Count participates in bounded canonical issue-count +comparison, including zero when MetronInfo omits it; malformed, repeated and +disagreeing counts remain reviewable rather than silently falling back. Production +owner rollback, shared mutation coordination, restart orchestration and no-op archive-write avoidance remain required. The existing import/cache consumers have not yet migrated to this registry. diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py index 55895092..c5b0fa37 100644 --- a/src/pullbox/services/archive_metadata_reconciliation.py +++ b/src/pullbox/services/archive_metadata_reconciliation.py @@ -323,6 +323,7 @@ def read(self) -> EmbeddedMetadataValues: "publisher": (self.read_field("Publisher"), "Publisher"), "language": (self.read_field("LanguageISO"), "LanguageISO"), "series_type": (self.read_field("Format"), "Format"), + "issue_count": (self.integer("Count", 0, 1000000), "Count"), }, document, self.diagnostics, diff --git a/tests/integration/metadata_identity/test_archive_metadata_finalization.py b/tests/integration/metadata_identity/test_archive_metadata_finalization.py index 0c20681f..17e88ac1 100644 --- a/tests/integration/metadata_identity/test_archive_metadata_finalization.py +++ b/tests/integration/metadata_identity/test_archive_metadata_finalization.py @@ -61,11 +61,16 @@ async def finish(factory, receipt, inspection): return result +@pytest.mark.parametrize( + "comicinfo", + [XML, XML.replace("2", "")], + ids=["preserved-volume", "canonical-fields-only"], +) async def test_finalization_adopts_canonical_values_and_file_evidence_together( - identity_probe_db, tmp_path + identity_probe_db, tmp_path, comicinfo ): _, factory, _ = identity_probe_db - async with prepared(factory, tmp_path, comicinfo=XML) as (path, _, plan): + async with prepared(factory, tmp_path, comicinfo=comicinfo) as (path, _, plan): binding = plan.target.binding async with factory.begin() as session: file = await session.get(LibraryFile, binding.library_file_id) diff --git a/tests/unit/test_archive_metadata_reconciliation.py b/tests/unit/test_archive_metadata_reconciliation.py index b1f1799e..f4e5c981 100644 --- a/tests/unit/test_archive_metadata_reconciliation.py +++ b/tests/unit/test_archive_metadata_reconciliation.py @@ -72,6 +72,44 @@ def test_agreement_and_complementary_values_feed_one_shared_view(): assert not result.requires_review +@pytest.mark.parametrize("count", [0, 1, 12, 1000000]) +@pytest.mark.parametrize("with_metron", [False, True]) +def test_comicinfo_issue_count_is_shared_metadata_not_unmapped_content(count, with_metron): + metron = None + if with_metron: + # MetronInfo permits positive counts only; the writer omits a zero count. + metron = mi().replace( + "", f"{count}" if count else "" + ) + result = reconcile_archive_metadata(files(ci(f"{count}"), metron)) + assert result.comicinfo.series.issue_count == count + assert result.series.issue_count == count + assert not result.requires_review + + +def test_comicinfo_and_metron_issue_count_disagreement_remains_reviewable(): + metron = mi().replace("", "13") + result = reconcile_archive_metadata(files(ci("12"), metron)) + assert result.series.issue_count is None + assert [ + (item.entity, item.field, item.comicinfo, item.metroninfo) for item in result.differences + ] == [("series", "issue_count", 12, 13)] + assert result.requires_review + + +@pytest.mark.parametrize("count", ["-1", "1000001", "12.5", "unknown", "9" * 11]) +def test_invalid_comicinfo_issue_count_is_not_adopted(count): + result = reconcile_archive_metadata(files(ci(f"{count}"))) + assert result.comicinfo.series.issue_count is None + assert ("ComicInfo.xml", "invalid_field", "Count") in codes(result) + + +def test_duplicate_comicinfo_issue_counts_are_ambiguous(): + result = reconcile_archive_metadata(files(ci("1213"))) + assert result.comicinfo.series.issue_count is None + assert ("ComicInfo.xml", "ambiguous_field", "Count") in codes(result) + + @pytest.mark.parametrize( "kind,notes,ids", [ From 0840ffdbc6253edf703f70b5ed25a45261bb30c2 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 04:37:42 -0700 Subject: [PATCH 69/97] feat(metadata): bind archive publication to import ownership --- docs/development/ARCHITECTURE_OVERVIEW.md | 11 +- docs/development/DATABASE_STANDARDS.md | 20 +- .../schemas/archive_publication_owner.py | 16 + .../services/archive_metadata_finalization.py | 6 + .../services/archive_metadata_publication.py | 11 + .../services/import_archive_publication.py | 293 +++++++++++ src/pullbox/services/import_job_actions.py | 26 +- .../test_import_archive_publication.py | 483 ++++++++++++++++++ 8 files changed, 861 insertions(+), 5 deletions(-) create mode 100644 src/pullbox/schemas/archive_publication_owner.py create mode 100644 src/pullbox/services/import_archive_publication.py create mode 100644 tests/integration/metadata_identity/test_import_archive_publication.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 169288d3..d33f2e37 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -583,9 +583,14 @@ normalize through a shared reversible mapping when read back; unknown formats an real disagreements remain intact. File timestamps retain the same stat conversion used by registration. ComicInfo Count participates in bounded canonical issue-count comparison, including zero when MetronInfo omits it; malformed, repeated and -disagreeing counts remain reviewable rather than silently falling back. Production -owner rollback, shared mutation coordination, -restart orchestration and no-op archive-write avoidance remain required. +disagreeing counts remain reviewable rather than silently falling back. +Import-owner binding now connects publication/finalization to the exact pending +import and placement action. Finalization acknowledges that owner and records a +successor reference without replacing original ownership evidence. Import rollback +accepts only a finalized, action-bound successor whose registered owner and actual +bytes still match. Active publications and later reassignment remain protected. +Shared mutation coordination, cancelled-owner settlement, restart/worker +orchestration and no-op archive-write avoidance remain required. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index a07c54cd..cf024f4c 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -496,7 +496,8 @@ links preserve the immutable intent when the library row is deleted; the path reservation and evidence survive. There is no age-based lease takeover. Services use caller-owned transactions and the shared SQLite write/savepoint -boundary. Publication locks source policies, series, issue, root, file, then the +boundary. Import-owned publication first locks its job, imported file and action; +publication then locks source policies, series, issue, root, file, then the journal row. Recovery only locks the journal row. Large file hashing, rendering and staging stay outside write transactions; only the short, offloaded atomic publication and directory-sync boundary retains the lock. An intent must commit @@ -520,6 +521,23 @@ failed finalization retains the published reservation; caught errors still roll back the nested write. Production workflow rollback and restart integration remain required before existing writers switch to this boundary. +An optional versioned-plan import owner binds deferred enrichment to its exact +completed import record and managed placement action. Bounded digests retain the +original action and pending-work snapshot; legacy journal JSON without an owner +remains readable. Owner cancellation, rollback, deletion or changed evidence +cannot authorize record, publication or finalization. Completed replay remains +historical even after the owner is deleted. + +Finalization stores the successor publication reference on the import action and +acknowledges deferred enrichment in the same transaction as canonical/file state. +It never replaces the original destination signature or source/naming evidence. +Import rollback rejects active publications, unverified successor pointers and +reassigned registrations. A successor must be finalized for that exact unchanged +action, and its verified fingerprint/digest must still match the actual archive. +The original source remains subject to the existing rollback policy. Shared +mutation fencing, cancelled-owner settlement and restart/worker integration are +still required before the background writer switches to paired publication. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/schemas/archive_publication_owner.py b/src/pullbox/schemas/archive_publication_owner.py new file mode 100644 index 00000000..3e0b0d6d --- /dev/null +++ b/src/pullbox/schemas/archive_publication_owner.py @@ -0,0 +1,16 @@ +"""Immutable invoking-owner evidence retained with archive publication intent.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class ImportArchiveOwner(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["import_enrichment"] = "import_enrichment" + job_id: int = Field(gt=0, strict=True) + imported_file_id: int = Field(gt=0, strict=True) + action_id: int = Field(gt=0, strict=True) + action_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + pending_digest: str = Field(pattern=r"^[a-f0-9]{64}$") diff --git a/src/pullbox/services/archive_metadata_finalization.py b/src/pullbox/services/archive_metadata_finalization.py index db27a859..dd49efb8 100644 --- a/src/pullbox/services/archive_metadata_finalization.py +++ b/src/pullbox/services/archive_metadata_finalization.py @@ -21,6 +21,10 @@ _stat_fingerprint, load_archive_publication, ) +from pullbox.services.import_archive_publication import ( + acknowledge_import_archive_publication, + require_import_archive_owner, +) from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_credits import write_issue_credits from pullbox.services.metadata_entity_values import ( @@ -85,6 +89,7 @@ async def finalize_archive_publication( if current.state is not PublicationState.PUBLISHED: raise ArchivePublicationError("publication_not_published") plan = receipt.plan + await require_import_archive_owner(session, plan) await revalidate_archive_metadata_target(session, plan.target) modified_at = await _file_work(lambda stop: _check_output(receipt, inspection)) bound = plan.target.binding.metadata @@ -115,6 +120,7 @@ async def finalize_archive_publication( file.file_modified_at = modified_at file.file_hash = plan.output_digest file.has_comicinfo = True + await acknowledge_import_archive_publication(session, receipt) await session.flush() await _file_work(lambda stop: _check_output(receipt, inspection)) row.state = PublicationState.FINALIZED diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index e71e1597..350f742e 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -22,6 +22,7 @@ from pullbox.models import Issue, LibraryFile, LibraryRoot, Series from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.schemas.archive_publication_owner import ImportArchiveOwner from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_binding import ( ArchiveMetadataTarget, @@ -57,6 +58,7 @@ class ArchivePublicationPlan(BaseModel): output_digest: str = Field(pattern=r"^[a-f0-9]{64}$") series: MetadataSnapshot issue: MetadataSnapshot + import_owner: ImportArchiveOwner | None = None @dataclass(frozen=True) @@ -239,6 +241,9 @@ async def load_archive_publication( async def _lock_binding(session: AsyncSession, plan: ArchivePublicationPlan) -> None: + from pullbox.services.import_archive_publication import lock_import_archive_owner + + await lock_import_archive_owner(session, plan.import_owner) bound = plan.target.binding await session.execute( select(MetadataSourceConfig.id) @@ -262,6 +267,8 @@ async def record_archive_publication( Reservations do not expire. Recovery needs the same row lock as publication; elapsed time is never permission to let another writer take over. """ + from pullbox.services.import_archive_publication import require_import_archive_owner + _clean_session(session) encoded = _encode(plan) path_key = hashlib.sha256(os.fsencode(plan.target.path)).hexdigest() @@ -286,6 +293,7 @@ async def record_archive_publication( ) if busy: raise ArchivePublicationError("publication_busy") + await require_import_archive_owner(session, plan) await revalidate_archive_metadata_target(session, plan.target) row = ArchiveMetadataPublication( operation_id=str(operation_id), @@ -332,6 +340,8 @@ async def publish_archive_publication( reservation remains until separate canonical DB finalization commits. Existing writers still require workflow ownership and rollback integration. """ + from pullbox.services.import_archive_publication import require_import_archive_owner + _clean_session(session) if ( session.info.get(f"archive_intent:{operation_id}") is session.sync_session.get_transaction() @@ -347,6 +357,7 @@ async def publish_archive_publication( if row.state is not PublicationState.INTENDED: raise ArchivePublicationError("publication_not_intended") plan = _receipt(row).plan + await require_import_archive_owner(session, plan) await revalidate_archive_metadata_target(session, plan.target) await _file_work(lambda stop: _publish_files(plan, stop)) row.state = PublicationState.PUBLISHED diff --git a/src/pullbox/services/import_archive_publication.py b/src/pullbox/services/import_archive_publication.py new file mode 100644 index 00000000..51246d22 --- /dev/null +++ b/src/pullbox/services/import_archive_publication.py @@ -0,0 +1,293 @@ +"""Bind paired archive publication to retained import placement evidence.""" + +import hashlib +import json +import os +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportedFileStatus, + ImportJob, + ImportJobAction, + ImportJobActionStatus, + ImportJobStatus, +) +from pullbox.models.library import LibraryFile, LibraryFileStorageMode +from pullbox.schemas.archive_publication_owner import ImportArchiveOwner +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + ArchivePublicationPlan, + ArchivePublicationReceipt, + _clean_session, + _receipt, +) +from pullbox.services.metadata_writer_identity import metadata_write_scope + +_ENRICHMENT = "comicinfo_enrichment" +_SUCCESSOR = "metadata_publication" + + +def _digest(value: object) -> str: + try: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + except (TypeError, ValueError): + raise ArchivePublicationError("import_owner_invalid") from None + if len(encoded) > 4 * 1024 * 1024: + raise ArchivePublicationError("import_owner_too_large") + return hashlib.sha256(encoded).hexdigest() + + +def _signature(plan: ArchivePublicationPlan, *, successor: bool = False) -> dict[str, int | str]: + fingerprint = plan.stage_fingerprint if successor else plan.target.fingerprint + return { + "schema_version": 1, + "resolved_path": str(plan.target.path), + "size": fingerprint[2], + "mtime_ns": fingerprint[3], + "device": fingerprint[0], + "inode": fingerprint[1], + "content_digest_algorithm": "sha256", + "content_digest": plan.output_digest if successor else plan.source_digest, + } + + +async def lock_import_archive_owner( + session: AsyncSession, owner: ImportArchiveOwner | None +) -> None: + """Lock the owner before library parents; replay may outlive these rows.""" + if owner is None: + return + await _lock_owner_rows(session, owner.job_id, owner.imported_file_id, owner.action_id) + + +async def _lock_owner_rows( + session: AsyncSession, job_id: int, imported_file_id: int, action_id: int +) -> None: + for model, local_id in ( + (ImportJob, job_id), + (ImportedFile, imported_file_id), + (ImportJobAction, action_id), + ): + await session.execute(select(model.id).where(model.id == local_id).with_for_update()) + + +async def _owner_snapshot( + session: AsyncSession, + plan: ArchivePublicationPlan, + *, + job_id: int, + imported_file_id: int, + action_id: int, +) -> ImportArchiveOwner: + job = await session.get(ImportJob, job_id, populate_existing=True) + file = await session.get(ImportedFile, imported_file_id, populate_existing=True) + action = await session.get(ImportJobAction, action_id, populate_existing=True) + bound = plan.target.binding + library_file = await session.get(LibraryFile, bound.library_file_id, populate_existing=True) + if ( + job is None + or file is None + or action is None + or library_file is None + or library_file.storage_mode is not LibraryFileStorageMode.MANAGED + or _digest(library_file.source_signature) != _digest(_signature(plan)) + or job.status is not ImportJobStatus.COMPLETED + or job.control_request is not ImportControlRequest.NONE + or file.import_job_id != job.id + or action.import_job_id != job.id + or file.status is not ImportedFileStatus.IMPORTED + or file.library_file_id != bound.library_file_id + or file.matched_issue_id != bound.metadata.issues[0].local_id + or action.status is not ImportJobActionStatus.COMPLETED + or action.action_type != "library_file_registered" + or action.phase != "import" + ): + raise ArchivePublicationError("import_owner_changed") + payload = action.payload + diagnostics = file.diagnostics + details = diagnostics.get(_ENRICHMENT) if isinstance(diagnostics, dict) else None + if ( + not isinstance(payload, dict) + or not isinstance(details, dict) + or any(type(payload.get(key)) is not int for key in ("imported_file_id", "library_file_id")) + or any(type(details.get(key)) is not int for key in ("library_file_id", "issue_id")) + or details.get("status") != "pending" + or details.get("library_file_id") != bound.library_file_id + or details.get("issue_id") != file.matched_issue_id + or details.get("artifact_path", str(plan.target.path)) != str(plan.target.path) + or payload.get("imported_file_id") != file.id + or payload.get("library_file_id") != bound.library_file_id + or payload.get("destination_path") != str(plan.target.path) + or payload.get("storage_mode") != "managed" + or payload.get("transfer_method") not in ("copy", "move", "hardlink") + or _digest(payload.get("destination_signature")) != _digest(_signature(plan)) + or payload.get("embedded_comicinfo_enrichment_deferred") is not True + or _SUCCESSOR in payload + ): + raise ArchivePublicationError("import_owner_changed") + return ImportArchiveOwner( + job_id=job.id, + imported_file_id=file.id, + action_id=action.id, + action_digest=_digest(payload), + pending_digest=_digest( + { + "details": details, + "path": file.file_path, + "series": file.import_series_id, + "source_signature": file.source_signature, + } + ), + ) + + +async def bind_import_archive_publication( + session: AsyncSession, + plan: ArchivePublicationPlan, + *, + imported_file_id: int, + action_id: int, +) -> ArchivePublicationPlan: + """Bind a prepared publication to its completed import placement.""" + _clean_session(session) + if any(type(value) is not int or value <= 0 for value in (imported_file_id, action_id)): + raise ArchivePublicationError("import_owner_invalid") + async with metadata_write_scope(session): + file = await session.get(ImportedFile, imported_file_id) + if file is None: + raise ArchivePublicationError("import_owner_missing") + job_id = file.import_job_id + await _lock_owner_rows(session, job_id, imported_file_id, action_id) + owner = await _owner_snapshot( + session, + plan, + job_id=job_id, + imported_file_id=imported_file_id, + action_id=action_id, + ) + if plan.import_owner is not None and plan.import_owner != owner: + raise ArchivePublicationError("import_owner_changed") + return plan.model_copy(update={"import_owner": owner}) + + +async def require_import_archive_owner(session: AsyncSession, plan: ArchivePublicationPlan) -> None: + owner = plan.import_owner + if owner is None: + return + current = await _owner_snapshot( + session, + plan, + job_id=owner.job_id, + imported_file_id=owner.imported_file_id, + action_id=owner.action_id, + ) + if current != owner: + raise ArchivePublicationError("import_owner_changed") + + +async def acknowledge_import_archive_publication( + session: AsyncSession, + receipt: ArchivePublicationReceipt, +) -> None: + """Caller already holds owner locks and verified the immutable owner snapshot.""" + owner = receipt.plan.import_owner + if owner is None: + return + action = await session.get(ImportJobAction, owner.action_id) + file = await session.get(ImportedFile, owner.imported_file_id) + assert action is not None and file is not None + action.payload = {**action.payload, _SUCCESSOR: str(receipt.operation_id)} + details = dict(file.diagnostics[_ENRICHMENT]) + details.update( + status="complete", + publication_id=str(receipt.operation_id), + completed_at=datetime.now(UTC).isoformat(), + ) + file.diagnostics = {**file.diagnostics, _ENRICHMENT: details} + + +async def import_rollback_signature( + session: AsyncSession, + action: ImportJobAction, +) -> object: + """Require a settled successor bound to this original action before rollback. + + Locks remain in the caller's transaction through removal. The original + signature is never replaced; a retained finalized publication proves only + the successor to compare with the actual filesystem, not permission to skip + that comparison. + """ + original_payload = dict(action.payload) + async with metadata_write_scope(session): + await session.execute( + select(ImportJob.id).where(ImportJob.id == action.import_job_id).with_for_update() + ) + await session.refresh(action, with_for_update=True) + if action.payload != original_payload: + raise ArchivePublicationError("import_owner_changed") + file_id = action.payload.get("library_file_id") + path = action.payload.get("destination_path") + if not isinstance(path, str): + raise ArchivePublicationError("import_owner_changed") + path_key = hashlib.sha256(os.fsencode(path)).hexdigest() + active = await session.scalar( + select(ArchiveMetadataPublication.id) + .where( + or_( + ArchiveMetadataPublication.active_file_id == file_id, + ArchiveMetadataPublication.active_path_key == path_key, + ) + ) + .limit(1) + ) + if active is not None: + raise ArchivePublicationError("import_owner_publication_pending") + if _SUCCESSOR not in action.payload: + return action.payload.get("destination_signature") + file = await session.scalar( + select(LibraryFile) + .where(LibraryFile.id == file_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + try: + operation_id = UUID(action.payload[_SUCCESSOR]) + except (ValueError, TypeError, AttributeError): + raise ArchivePublicationError("import_owner_successor_invalid") from None + row = await session.scalar( + select(ArchiveMetadataPublication) + .where(ArchiveMetadataPublication.operation_id == str(operation_id)) + .with_for_update() + ) + if row is None or row.state is not PublicationState.FINALIZED: + raise ArchivePublicationError("import_owner_successor_invalid") + receipt = _receipt(row) + owner = receipt.plan.import_owner + binding = receipt.plan.target.binding + original = {key: value for key, value in action.payload.items() if key != _SUCCESSOR} + if ( + owner is None + or file is None + or file.storage_mode is not LibraryFileStorageMode.MANAGED + or file.issue_id != binding.metadata.issues[0].local_id + or file.library_root_id != binding.library_root_id + or file.file_path != str(receipt.plan.target.path) + or file.file_name != receipt.plan.target.path.name + or _digest(file.source_signature) != _digest(_signature(receipt.plan)) + or owner.action_id != action.id + or owner.job_id != action.import_job_id + or owner.imported_file_id != action.payload.get("imported_file_id") + or owner.action_digest != _digest(original) + or receipt.plan.target.binding.library_file_id != file_id + or str(receipt.plan.target.path) != path + or action.payload.get("destination_signature") != _signature(receipt.plan) + ): + raise ArchivePublicationError("import_owner_successor_invalid") + return _signature(receipt.plan, successor=True) diff --git a/src/pullbox/services/import_job_actions.py b/src/pullbox/services/import_job_actions.py index d8da0267..9167f2a9 100644 --- a/src/pullbox/services/import_job_actions.py +++ b/src/pullbox/services/import_job_actions.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, TypedDict +import structlog from sqlalchemy import false, or_ from sqlalchemy import func as sa_func from sqlalchemy import insert as sa_insert @@ -72,6 +73,7 @@ _ACTION_SEQUENCE_CACHE_KEY = "pullbox.import_action_last_sequence" +logger = structlog.get_logger(__name__) _ACTION_INSERT_BATCH_SIZE = 200 _STORY_ARC_MANAGED_PLACEMENT_ACTION = "story_arc_managed_placement_requested" _STORY_ARC_PLACEMENT_PHASE = "story_arc_placements" @@ -810,6 +812,28 @@ async def rollback_action( elif action_type == "story_arc_created": await _rollback_created_story_arc(session, action, payload) elif action_type == "library_file_registered": + from pullbox.services.archive_metadata_publication import ArchivePublicationError + from pullbox.services.import_archive_publication import import_rollback_signature + + if action.status is ImportJobActionStatus.ROLLED_BACK: + return + try: + rollback_signature = await import_rollback_signature(session, action) + except ArchivePublicationError as exc: + logger.warning( + "import_archive_rollback_preserved", + action_id=action.id, + job_id=action.import_job_id, + reason=exc.code, + ) + action.status = ImportJobActionStatus.ROLLBACK_FAILED + action.error_message = ( + "Archive metadata publication is pending or its import ownership changed. " + "Pullbox preserved the file for recovery." + ) + action.rolled_back_at = None + await session.flush() + return library_file_id = int(payload.get("library_file_id") or 0) destination_path = Path(str(payload.get("destination_path") or "")) original_source_path = Path(str(payload.get("original_source_path") or "")) @@ -859,7 +883,7 @@ async def rollback_action( and not _managed_destination_matches_rollback_action( destination_path, library_file=library_file, - expected_signature=payload.get("destination_signature"), + expected_signature=rollback_signature, ) ): action.status = ImportJobActionStatus.ROLLBACK_FAILED diff --git a/tests/integration/metadata_identity/test_import_archive_publication.py b/tests/integration/metadata_identity/test_import_archive_publication.py new file mode 100644 index 00000000..3aa5b436 --- /dev/null +++ b/tests/integration/metadata_identity/test_import_archive_publication.py @@ -0,0 +1,483 @@ +"""Import-owned enrichment retains the original proof and a verified successor.""" + +import asyncio +import threading +from contextlib import asynccontextmanager +from copy import deepcopy +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, update +from sqlalchemy.orm.attributes import flag_modified + +from pullbox.core.library_file_ownership import build_managed_placement_signature +from pullbox.models import LibraryFile, LibraryRoot +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobAction, + ImportJobActionStatus, + ImportJobStatus, + ImportSourceType, +) +from pullbox.services.archive_metadata_finalization import finalize_archive_publication +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + inspect_archive_publication, + load_archive_publication, + publish_archive_publication, + reconcile_archive_publication, +) +from pullbox.services.import_archive_publication import bind_import_archive_publication +from pullbox.services.import_job_actions import rollback_action +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + load, + prepared, + record, +) + + +@asynccontextmanager +async def owned(factory, tmp_path): + async with prepared(factory, tmp_path) as (path, _, plan): + source = tmp_path / "source.cbz" + source.write_bytes(path.read_bytes()) + signature = build_managed_placement_signature(path) + bound = plan.target.binding + async with factory.begin() as session: + job = ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + session.add(job) + await session.flush() + series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Example", + series_id=bound.metadata.series.local_id, + ) + session.add(series) + await session.flush() + file = ImportedFile( + import_job_id=job.id, + import_series_id=series.id, + file_path=str(source), + file_name=source.name, + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=bound.metadata.issues[0].local_id, + library_file_id=bound.library_file_id, + diagnostics={ + "keep": {"evidence": True}, + "comicinfo_enrichment": { + "status": "pending", + "reason": "deferred_during_import", + "library_file_id": bound.library_file_id, + "issue_id": bound.metadata.issues[0].local_id, + "artifact_path": str(path), + }, + }, + ) + session.add(file) + await session.flush() + action = ImportJobAction( + import_job_id=job.id, + sequence_no=1, + phase="import", + action_type="library_file_registered", + payload={ + "imported_file_id": file.id, + "library_file_id": bound.library_file_id, + "destination_path": str(path), + "destination_signature": signature, + "original_source_path": str(source), + "transfer_method": "copy", + "storage_mode": "managed", + "embedded_comicinfo_enrichment_deferred": True, + }, + ) + session.add(action) + library = await session.get(LibraryFile, bound.library_file_id) + library.source_signature = signature + await session.flush() + ids = job.id, file.id, action.id + async with factory() as session: + plan = await bind_import_archive_publication( + session, plan, imported_file_id=ids[1], action_id=ids[2] + ) + yield path, source, plan, ids, signature + + +async def publish(factory, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + return await publish_archive_publication(session, saved.operation_id) + + +async def finish(factory, receipt): + inspection = await inspect_archive_publication(receipt) + async with factory.begin() as session: + return await finalize_archive_publication(session, receipt, inspection) + + +async def rollback(factory, action_id): + async with factory.begin() as session: + action = await session.get(ImportJobAction, action_id) + await rollback_action( + session, + action_id=action.id, + action_type=action.action_type, + payload=deepcopy(action.payload), + delete_series=AsyncMock(), + ) + async with factory() as session: + return (await session.get(ImportJobAction, action_id)).status + + +async def test_finalization_acknowledges_owner_and_rollback_accepts_only_successor( + identity_probe_db, + tmp_path, +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, signature): + original = source.read_bytes() + saved = await publish(factory, plan) + completed = await finish(factory, saved) + async with factory() as session: + action = await session.get(ImportJobAction, ids[2]) + assert action.payload["destination_signature"] == signature + assert action.payload.get("metadata_publication") == str(saved.operation_id) + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["keep"] == {"evidence": True} + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + assert file.diagnostics["comicinfo_enrichment"]["publication_id"] == str( + saved.operation_id + ) + library = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert library.source_signature == signature + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + assert not path.exists() + assert source.read_bytes() == original + assert await load(factory, saved.operation_id) == completed + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + + +async def test_pre_owner_journal_json_remains_readable(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values( + plan_json=plan.model_dump_json(exclude={"import_owner"}) + ) + ) + async with factory() as session: + assert await load_archive_publication(session, saved.operation_id) == saved + + +async def test_owner_acknowledgment_rolls_back_with_canonical_finalization( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + saved = await publish(factory, plan) + inspection = await inspect_archive_publication(saved) + async with factory() as session: + await finalize_archive_publication(session, saved, inspection) + await session.rollback() + async with factory() as session: + assert ( + "metadata_publication" not in (await session.get(ImportJobAction, ids[2])).payload + ) + assert (await session.get(ImportedFile, ids[1])).diagnostics["comicinfo_enrichment"][ + "status" + ] == "pending" + assert await load(factory, saved.operation_id) == saved + await finish(factory, saved) + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + + +@pytest.mark.parametrize("boundary", ["record", "publish", "finalize"]) +@pytest.mark.parametrize( + "change", ["cancel", "rollback", "action", "imported_file", "registration", "delete"] +) +async def test_changed_owner_never_authorizes_mutation( + identity_probe_db, tmp_path, boundary, change +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + saved = None + if boundary != "record": + saved = await record(factory, plan) + if boundary == "finalize": + async with factory.begin() as session: + saved = await publish_archive_publication(session, saved.operation_id) + async with factory.begin() as session: + if change == "cancel": + await session.execute( + update(ImportJob).values(control_request=ImportControlRequest.CANCEL) + ) + elif change == "rollback": + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + elif change == "action": + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "original_source_path": "changed"} + elif change == "imported_file": + await session.execute( + update(ImportedFile).values(status=ImportedFileStatus.SKIPPED) + ) + elif change == "registration": + await session.execute( + update(LibraryFile).values(source_signature={"later": "owner"}) + ) + else: + await session.execute(delete(ImportJob).where(ImportJob.id == ids[0])) + before = path.read_bytes() + with pytest.raises(ArchivePublicationError, match="import_owner"): + if boundary == "record": + await record(factory, plan) + elif boundary == "publish": + async with factory.begin() as session: + await publish_archive_publication(session, saved.operation_id) + else: + await finish(factory, saved) + assert path.read_bytes() == before + if saved: + assert await load(factory, saved.operation_id) == saved + + +@pytest.mark.parametrize( + "change", ["reference", "signature", "action_type", "target", "not_pending"] +) +async def test_owner_binding_rejects_unowned_placement(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + async with factory.begin() as session: + action = await session.get(ImportJobAction, ids[2]) + payload = dict(action.payload) + if change == "reference": + payload["storage_mode"] = "referenced" + payload["transfer_method"] = "leave_in_place" + elif change == "signature": + payload["destination_signature"] = {"content_digest": "a" * 64} + elif change == "action_type": + action.action_type = "series_created" + elif change == "target": + payload["library_file_id"] = 999 + else: + file = await session.get(ImportedFile, ids[1]) + file.diagnostics = {"comicinfo_enrichment": {"status": "complete"}} + action.payload = payload + with pytest.raises(ArchivePublicationError, match="import_owner"): + async with factory() as session: + await bind_import_archive_publication( + session, + plan.model_copy(update={"import_owner": None}), + imported_file_id=ids[1], + action_id=ids[2], + ) + + +async def test_rename_commit_gap_retains_owner_and_recovers_once(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, _): + saved = await record(factory, plan) + async with factory() as session: + await publish_archive_publication(session, saved.operation_id) + await session.rollback() + before = path.read_bytes(), path.stat().st_ino + saved = await load(factory, saved.operation_id) + assert getattr(saved.plan, "import_owner", None) is not None + async with factory.begin() as session: + saved = await reconcile_archive_publication( + session, saved, await inspect_archive_publication(saved) + ) + completed = await finish(factory, saved) + assert await finish(factory, saved) == completed + assert (path.read_bytes(), path.stat().st_ino) == before + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + assert source.exists() + + +@pytest.mark.parametrize("state", ["intended", "published", "finalized_changed"]) +async def test_rollback_preserves_unsettled_or_user_changed_archive( + identity_probe_db, tmp_path, state +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, _): + saved = await record(factory, plan) + if state != "intended": + async with factory.begin() as session: + saved = await publish_archive_publication(session, saved.operation_id) + if state == "finalized_changed": + await finish(factory, saved) + path.write_bytes(b"user replacement") + before = path.read_bytes() + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLBACK_FAILED + assert path.read_bytes() == before and source.exists() + + +async def test_unverified_successor_pointer_cannot_authorize_deletion(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, _, ids, _): + async with factory.begin() as session: + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "metadata_publication": str(uuid4())} + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLBACK_FAILED + assert path.exists() + + +async def test_concurrent_finalizers_acknowledge_import_once(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + saved = await publish(factory, plan) + results = await asyncio.gather(finish(factory, saved), finish(factory, saved)) + assert results[0] == results[1] + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + + +async def test_completed_replay_survives_deleted_import_without_recreating_it( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + saved = await publish(factory, plan) + completed = await finish(factory, saved) + async with factory.begin() as session: + await session.execute(delete(ImportJob).where(ImportJob.id == ids[0])) + path.write_bytes(b"later edit") + assert await finish(factory, saved) == completed + async with factory() as session: + assert await session.get(ImportJob, ids[0]) is None + assert path.read_bytes() == b"later edit" + + +@pytest.mark.parametrize("change", ["different_action", "original_proof", "source_path"]) +async def test_finalized_receipt_cannot_be_repurposed(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + saved = await publish(factory, plan) + await finish(factory, saved) + action_id = ids[2] + async with factory.begin() as session: + action = await session.get(ImportJobAction, action_id) + payload = dict(action.payload) + if change == "different_action": + other = ImportJobAction( + import_job_id=ids[0], + sequence_no=2, + phase="import", + action_type=action.action_type, + payload=payload, + ) + session.add(other) + await session.flush() + action_id = other.id + elif change == "original_proof": + payload["destination_signature"] = build_managed_placement_signature(path) + action.payload = payload + else: + action.payload = {**payload, "original_source_path": "different/source.cbz"} + assert await rollback(factory, action_id) is ImportJobActionStatus.ROLLBACK_FAILED + assert path.exists() + + +@pytest.mark.parametrize("change", ["boolean_id", "diagnostics_shape", "transfer_shape"]) +async def test_malformed_owner_evidence_has_bounded_error(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + async with factory.begin() as session: + action = await session.get(ImportJobAction, ids[2]) + if change == "boolean_id": + action.payload = {**action.payload, "imported_file_id": True} + flag_modified(action, "payload") + elif change == "diagnostics_shape": + file = await session.get(ImportedFile, ids[1]) + file.diagnostics = ["legacy malformed data"] + else: + action.payload = {**action.payload, "transfer_method": ["copy"]} + with pytest.raises(ArchivePublicationError, match="import_owner"): + async with factory() as session: + await bind_import_archive_publication( + session, + plan.model_copy(update={"import_owner": None}), + imported_file_id=ids[1], + action_id=ids[2], + ) + + +async def test_cancellation_cannot_cross_an_active_publication_lock( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import archive_metadata_publication as publication + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + saved = await record(factory, plan) + entered, release = threading.Event(), threading.Event() + real_publish = publication._publish_files + + def held(plan, stop): + entered.set() + assert release.wait(10) + return real_publish(plan, stop) + + async def run_publish(): + async with factory.begin() as session: + return await publish_archive_publication(session, saved.operation_id) + + async def cancel(): + async with factory.begin() as session: + await session.execute( + update(ImportJob) + .where(ImportJob.id == ids[0]) + .values(control_request=ImportControlRequest.CANCEL) + ) + + monkeypatch.setattr(publication, "_publish_files", held) + publishing = asyncio.create_task(run_publish()) + cancelling = None + try: + assert await asyncio.to_thread(entered.wait, 10) + cancelling = asyncio.create_task(cancel()) + await asyncio.sleep(0.05) + assert not cancelling.done() + finally: + release.set() + published = await publishing + if cancelling: + await cancelling + before = path.read_bytes() + with pytest.raises(ArchivePublicationError, match="import_owner"): + await finish(factory, published) + assert path.read_bytes() == before + + +@pytest.mark.parametrize("change", ["issue", "root", "source_evidence"]) +async def test_reassigned_registration_is_not_removed_by_old_import( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + saved = await publish(factory, plan) + await finish(factory, saved) + async with factory.begin() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + if change == "issue": + file.issue_id = None + elif change == "root": + root = LibraryRoot(name="Reassigned", path=str(tmp_path / "other-root")) + session.add(root) + await session.flush() + file.library_root_id = root.id + else: + file.source_signature = {"later": "registration evidence"} + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLBACK_FAILED + assert path.exists() From b796b7247e984f72729605fba0e4e8878facdc67 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 05:09:18 -0700 Subject: [PATCH 70/97] fix(metadata): recover stopped import archive publications --- ...234_settle_stopped_archive_publications.py | 39 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 8 +- docs/development/DATABASE_STANDARDS.md | 23 +- .../models/archive_metadata_publication.py | 5 +- .../services/archive_metadata_publication.py | 2 +- .../services/import_archive_publication.py | 29 +- .../services/import_archive_recovery.py | 98 +++++ .../services/import_comicinfo_enrichment.py | 53 ++- .../services/import_rollback_execution.py | 3 + src/pullbox/tasks/import_archive_recovery.py | 92 ++++ .../test_import_archive_recovery.py | 406 ++++++++++++++++++ .../test_import_archive_recovery_migration.py | 77 ++++ tests/integration/test_migrations.py | 3 +- .../unit/test_import_comicinfo_enrichment.py | 9 +- 14 files changed, 827 insertions(+), 20 deletions(-) create mode 100644 alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py create mode 100644 src/pullbox/services/import_archive_recovery.py create mode 100644 src/pullbox/tasks/import_archive_recovery.py create mode 100644 tests/integration/metadata_identity/test_import_archive_recovery.py create mode 100644 tests/integration/metadata_identity/test_import_archive_recovery_migration.py diff --git a/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py b/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py new file mode 100644 index 00000000..ef5f9274 --- /dev/null +++ b/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py @@ -0,0 +1,39 @@ +"""Retain stopped-owner publication evidence independently of canonical adoption.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "a8u9v0w1x234" +down_revision = "z7t8u9v0w123" +branch_labels = None +depends_on = None + + +def _constraints(*, settled: bool) -> None: + terminal = "'abandoned','finalized'" + (",'settled'" if settled else "") + states = "'intended','published','abandoned','review','finalized'" + ( + ",'settled'" if settled else "" + ) + with op.batch_alter_table("archive_metadata_publications") as batch: + batch.drop_constraint("archive_publication_state", type_="check") + batch.drop_constraint("ck_archive_publication_reservation", type_="check") + batch.create_check_constraint("archive_publication_state", f"state IN ({states})") + batch.create_check_constraint( + "ck_archive_publication_reservation", + f"(state IN ({terminal}) AND active_file_id IS NULL AND active_path_key IS NULL) OR " + f"(state NOT IN ({terminal}) AND active_path_key IS NOT NULL)", + ) + + +def upgrade() -> None: + _constraints(settled=True) + + +def downgrade() -> None: + table = sa.table("archive_metadata_publications", sa.column("state")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.state == "settled") + ): + raise RuntimeError("Cannot downgrade settled archive publication evidence.") + _constraints(settled=False) diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index d33f2e37..4f16af85 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -589,8 +589,12 @@ import and placement action. Finalization acknowledges that owner and records a successor reference without replacing original ownership evidence. Import rollback accepts only a finalized, action-bound successor whose registered owner and actual bytes still match. Active publications and later reassignment remain protected. -Shared mutation coordination, cancelled-owner settlement, restart/worker -orchestration and no-op archive-write avoidance remain required. +Startup/enrichment and rollback orchestration now recover retained import-owned +publications before using the legacy writer or removing artifacts. Stopped owners +receive a separate settled receipt and file accounting, not a stale canonical +update; unresolved reservations block legacy enrichment. Recovery never repeats +a filesystem replacement. Shared mutation coordination across other writers, +paired enrichment-worker activation and no-op archive-write avoidance remain required. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index cf024f4c..1d0c72f7 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -498,7 +498,8 @@ reservation and evidence survive. There is no age-based lease takeover. Services use caller-owned transactions and the shared SQLite write/savepoint boundary. Import-owned publication first locks its job, imported file and action; publication then locks source policies, series, issue, root, file, then the -journal row. Recovery only locks the journal row. Large file hashing, rendering +journal row. Generic filesystem classification only locks the journal row; +import-owner recovery uses the full owner/binding lock order. Large file hashing, rendering and staging stay outside write transactions; only the short, offloaded atomic publication and directory-sync boundary retains the lock. An intent must commit before publication. A failed post-rename transaction leaves evidence for later @@ -534,9 +535,23 @@ It never replaces the original destination signature or source/naming evidence. Import rollback rejects active publications, unverified successor pointers and reassigned registrations. A successor must be finalized for that exact unchanged action, and its verified fingerprint/digest must still match the actual archive. -The original source remains subject to the existing rollback policy. Shared -mutation fencing, cancelled-owner settlement and restart/worker integration are -still required before the background writer switches to paired publication. +The original source remains subject to the existing rollback policy. + +A stopped owner may receive a `settled` receipt for a proven published successor. +It updates only registered file accounting and the import's successor reference, +marking enrichment cancelled without applying canonical snapshots or baselines. +The immutable original placement proof and exact current registration must still +match. Settled receipts release reservations and survive rollback; downgrade +refuses to discard them. A proven untouched intent can instead be abandoned. +Changed or unproven files retain reservations and cannot fall through to legacy +enrichment. Neither recovery path writes an archive. + +The recovery task owns a clean session and commits bounded keyset reads before +inspection/hashing, then commits each recovered receipt independently. It rejects +pending caller edits and nested transactions. Startup/enrichment and rollback +orchestration invoke it under the existing process-local import lane. Shared +mutation fencing across all other writers and paired enrichment-worker activation +remain required; the process-local lane is not a cross-process filesystem lock. ### Source Catalog Checkpoints diff --git a/src/pullbox/models/archive_metadata_publication.py b/src/pullbox/models/archive_metadata_publication.py index ae742af0..af4c8504 100644 --- a/src/pullbox/models/archive_metadata_publication.py +++ b/src/pullbox/models/archive_metadata_publication.py @@ -14,6 +14,7 @@ class PublicationState(StrEnum): ABANDONED = "abandoned" REVIEW = "review" FINALIZED = "finalized" + SETTLED = "settled" class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin): @@ -24,9 +25,9 @@ class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin): "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size" ), CheckConstraint( - "(state IN ('abandoned','finalized') AND active_file_id IS NULL AND " + "(state IN ('abandoned','finalized','settled') AND active_file_id IS NULL AND " "active_path_key IS NULL) OR " - "(state NOT IN ('abandoned','finalized') AND active_path_key IS NOT NULL)", + "(state NOT IN ('abandoned','finalized','settled') AND active_path_key IS NOT NULL)", name="ck_archive_publication_reservation", ), ) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index 350f742e..c32c77d9 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -436,7 +436,7 @@ async def reconcile_archive_publication( receipt.revision, ): raise ArchivePublicationError("publication_changed") - if row.state is PublicationState.FINALIZED: + if row.state in {PublicationState.FINALIZED, PublicationState.SETTLED}: return receipt _directories_unchanged(receipt.plan) if _fingerprint(receipt.plan.target.path) != inspection.fingerprint: diff --git a/src/pullbox/services/import_archive_publication.py b/src/pullbox/services/import_archive_publication.py index 51246d22..240f0458 100644 --- a/src/pullbox/services/import_archive_publication.py +++ b/src/pullbox/services/import_archive_publication.py @@ -34,6 +34,16 @@ _SUCCESSOR = "metadata_publication" +def import_archive_owner_stopped(job: ImportJob) -> bool: + """Only durable stop/rollback controls permit non-canonical settlement.""" + return job.control_request is ImportControlRequest.CANCEL or job.status in { + ImportJobStatus.CANCELLING, + ImportJobStatus.CANCELLED, + ImportJobStatus.ROLLING_BACK, + ImportJobStatus.ROLLED_BACK, + } + + def _digest(value: object) -> str: try: encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() @@ -85,6 +95,7 @@ async def _owner_snapshot( job_id: int, imported_file_id: int, action_id: int, + stopped: bool = False, ) -> ImportArchiveOwner: job = await session.get(ImportJob, job_id, populate_existing=True) file = await session.get(ImportedFile, imported_file_id, populate_existing=True) @@ -97,9 +108,17 @@ async def _owner_snapshot( or action is None or library_file is None or library_file.storage_mode is not LibraryFileStorageMode.MANAGED + or library_file.issue_id != bound.metadata.issues[0].local_id + or library_file.library_root_id != bound.library_root_id + or library_file.file_path != str(plan.target.path) + or library_file.file_name != plan.target.path.name or _digest(library_file.source_signature) != _digest(_signature(plan)) - or job.status is not ImportJobStatus.COMPLETED - or job.control_request is not ImportControlRequest.NONE + or ( + not import_archive_owner_stopped(job) + if stopped + else job.status is not ImportJobStatus.COMPLETED + or job.control_request is not ImportControlRequest.NONE + ) or file.import_job_id != job.id or action.import_job_id != job.id or file.status is not ImportedFileStatus.IMPORTED @@ -195,6 +214,8 @@ async def require_import_archive_owner(session: AsyncSession, plan: ArchivePubli async def acknowledge_import_archive_publication( session: AsyncSession, receipt: ArchivePublicationReceipt, + *, + cancelled: bool = False, ) -> None: """Caller already holds owner locks and verified the immutable owner snapshot.""" owner = receipt.plan.import_owner @@ -206,7 +227,7 @@ async def acknowledge_import_archive_publication( action.payload = {**action.payload, _SUCCESSOR: str(receipt.operation_id)} details = dict(file.diagnostics[_ENRICHMENT]) details.update( - status="complete", + status="cancelled" if cancelled else "complete", publication_id=str(receipt.operation_id), completed_at=datetime.now(UTC).isoformat(), ) @@ -266,7 +287,7 @@ async def import_rollback_signature( .where(ArchiveMetadataPublication.operation_id == str(operation_id)) .with_for_update() ) - if row is None or row.state is not PublicationState.FINALIZED: + if row is None or row.state not in {PublicationState.FINALIZED, PublicationState.SETTLED}: raise ArchivePublicationError("import_owner_successor_invalid") receipt = _receipt(row) owner = receipt.plan.import_owner diff --git a/src/pullbox/services/import_archive_recovery.py b/src/pullbox/services/import_archive_recovery.py new file mode 100644 index 00000000..28d3b363 --- /dev/null +++ b/src/pullbox/services/import_archive_recovery.py @@ -0,0 +1,98 @@ +"""Settle retained import publication without repeating filesystem mutation.""" + +from dataclasses import replace + +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models import LibraryFile +from pullbox.models.archive_metadata_publication import PublicationState +from pullbox.models.import_job import ImportJob +from pullbox.services.archive_metadata_finalization import ( + _check_output, + finalize_archive_publication, +) +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + ArchivePublicationInspection, + ArchivePublicationReceipt, + _clean_session, + _file_work, + _lock_binding, + _locked_row, + _receipt, + load_archive_publication, + reconcile_archive_publication, +) +from pullbox.services.import_archive_publication import ( + _owner_snapshot, + acknowledge_import_archive_publication, + import_archive_owner_stopped, +) +from pullbox.services.metadata_writer_identity import metadata_write_scope + + +async def recover_import_archive_publication( + session: AsyncSession, + receipt: ArchivePublicationReceipt, + inspection: ArchivePublicationInspection, +) -> ArchivePublicationReceipt: + """Classify and settle one inspected import-owned publication. + + Never republish or restore an archive. A stopped owner receives file accounting + and successor proof only, not a stale canonical metadata update. All writes + belong to the caller's transaction; inspection/hashing happens before it. + """ + _clean_session(session) + owner = receipt.plan.import_owner + if owner is None: + raise ArchivePublicationError("import_owner_missing") + if (inspection.operation_id, inspection.revision) != (receipt.operation_id, receipt.revision): + raise ArchivePublicationError("publication_changed") + async with metadata_write_scope(session): + known = await load_archive_publication(session, receipt.operation_id) + if known is None or known.plan != receipt.plan: + raise ArchivePublicationError("publication_changed") + await _lock_binding(session, known.plan) + row = await _locked_row(session, receipt.operation_id) + current = _receipt(row) + if current.state in { + PublicationState.FINALIZED, + PublicationState.SETTLED, + PublicationState.ABANDONED, + }: + return current + if current != receipt: + raise ArchivePublicationError("publication_changed") + current = await reconcile_archive_publication(session, receipt, inspection) + if current.state is not PublicationState.PUBLISHED: + return current + inspection = replace(inspection, revision=current.revision) + job = await session.get(ImportJob, owner.job_id, populate_existing=True) + if job is None or not import_archive_owner_stopped(job): + return await finalize_archive_publication(session, current, inspection) + snapshot = await _owner_snapshot( + session, + current.plan, + job_id=owner.job_id, + imported_file_id=owner.imported_file_id, + action_id=owner.action_id, + stopped=True, + ) + if snapshot != owner: + raise ArchivePublicationError("import_owner_changed") + modified_at = await _file_work(lambda stop: _check_output(current, inspection)) + file = await session.get(LibraryFile, current.plan.target.binding.library_file_id) + assert file is not None and inspection.fingerprint is not None + file.file_size = inspection.fingerprint[2] + file.file_modified_at = modified_at + file.file_hash = current.plan.output_digest + file.has_comicinfo = True + await acknowledge_import_archive_publication(session, current, cancelled=True) + await session.flush() + await _file_work(lambda stop: _check_output(current, inspection)) + row.state = PublicationState.SETTLED + row.revision += 1 + row.active_file_id = None + row.active_path_key = None + await session.flush() + return _receipt(row) diff --git a/src/pullbox/services/import_comicinfo_enrichment.py b/src/pullbox/services/import_comicinfo_enrichment.py index 290021e3..54948d9d 100644 --- a/src/pullbox/services/import_comicinfo_enrichment.py +++ b/src/pullbox/services/import_comicinfo_enrichment.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import hashlib import inspect +import os from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -11,6 +13,7 @@ from typing import TYPE_CHECKING, Any import structlog +from sqlalchemy import or_ from sqlalchemy import select as sa_select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import joinedload @@ -20,7 +23,14 @@ is_sqlite_locked_error, sqlite_lock_retry_delay, ) -from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportJob, ImportJobStatus +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportedFileStatus, + ImportJob, + ImportJobStatus, +) from pullbox.models.issue import Issue from pullbox.models.library import LibraryFile from pullbox.models.series import Series @@ -120,6 +130,10 @@ async def run_pending_import_comicinfo_enrichment( prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> int: """Refresh deferred ComicInfo metadata for all completed jobs with pending rows.""" + from pullbox.tasks.import_archive_recovery import recover_import_archive_publications + + async with comicinfo_enrichment_gate(), session_factory() as session: + await recover_import_archive_publications(session) pending_job_ids = await _load_pending_import_job_ids(session_factory) for job_id in pending_job_ids: await run_import_comicinfo_enrichment( @@ -167,6 +181,10 @@ async def _run_import_comicinfo_enrichment_while_fenced( prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> None: """Run one job while holding the process-local filesystem mutation fence.""" + from pullbox.tasks.import_archive_recovery import recover_import_archive_publications + + async with session_factory() as session: + await recover_import_archive_publications(session, job_id=job_id) if not await _import_job_is_completed(session_factory, job_id=job_id): return pending_ids = await _load_pending_imported_file_ids(session_factory, job_id=job_id) @@ -241,7 +259,11 @@ async def _import_job_is_completed( ) -> bool: """Read durable job state at a filesystem safe boundary.""" async with session_factory() as session: - status = await session.scalar(sa_select(ImportJob.status).where(ImportJob.id == job_id)) + status = await session.scalar( + sa_select(ImportJob.status).where( + ImportJob.id == job_id, ImportJob.control_request == ImportControlRequest.NONE + ) + ) await session.rollback() return status is ImportJobStatus.COMPLETED @@ -257,6 +279,7 @@ async def _load_pending_imported_file_ids( .join(ImportJob, ImportedFile.import_job_id == ImportJob.id) .where(ImportedFile.import_job_id == job_id) .where(ImportJob.status == ImportJobStatus.COMPLETED) + .where(ImportJob.control_request == ImportControlRequest.NONE) .where(ImportedFile.status == ImportedFileStatus.IMPORTED) ) ids: list[int] = [] @@ -279,6 +302,7 @@ async def _load_pending_issue_cv_ids( .join(ImportJob, ImportedFile.import_job_id == ImportJob.id) .where(ImportedFile.import_job_id == job_id) .where(ImportJob.status == ImportJobStatus.COMPLETED) + .where(ImportJob.control_request == ImportControlRequest.NONE) .where(ImportedFile.status == ImportedFileStatus.IMPORTED) .order_by(ImportedFile.id) ) @@ -310,6 +334,7 @@ async def _load_pending_import_job_ids( sa_select(ImportedFile.import_job_id, ImportedFile.diagnostics) .join(ImportJob, ImportedFile.import_job_id == ImportJob.id) .where(ImportJob.status == ImportJobStatus.COMPLETED) + .where(ImportJob.control_request == ImportControlRequest.NONE) .where(ImportedFile.status == ImportedFileStatus.IMPORTED) .order_by(ImportedFile.import_job_id) ) @@ -389,7 +414,10 @@ async def _prepare_pending_imported_file( return None assert imported_file is not None job_status = await session.scalar( - sa_select(ImportJob.status).where(ImportJob.id == imported_file.import_job_id) + sa_select(ImportJob.status).where( + ImportJob.id == imported_file.import_job_id, + ImportJob.control_request == ImportControlRequest.NONE, + ) ) if job_status is not ImportJobStatus.COMPLETED: return None @@ -411,6 +439,25 @@ async def _prepare_pending_imported_file( if library_file is None: raise ValueError(f"Library file {library_file_id} no longer exists") + reserved = await session.scalar( + sa_select(ArchiveMetadataPublication.id) + .where( + or_( + ArchiveMetadataPublication.active_file_id == library_file.id, + ArchiveMetadataPublication.active_path_key + == hashlib.sha256(os.fsencode(library_file.file_path)).hexdigest(), + ) + ) + .limit(1) + ) + if reserved is not None: + logger.warning( + "import_comicinfo_publication_pending", + imported_file_id=imported_file_id, + publication_id=reserved, + ) + return None + recorded_issue_id = _recorded_positive_int(details, "issue_id") expected_issue_id = imported_file.matched_issue_id or recorded_issue_id if expected_issue_id is not None and library_file.issue_id != expected_issue_id: diff --git a/src/pullbox/services/import_rollback_execution.py b/src/pullbox/services/import_rollback_execution.py index 9bed442a..c117385c 100644 --- a/src/pullbox/services/import_rollback_execution.py +++ b/src/pullbox/services/import_rollback_execution.py @@ -192,7 +192,10 @@ async def rollback_import_job( progress_callback: ProgressCallback | None = None, ) -> bool: """Rollback durable import actions in reverse execution order.""" + from pullbox.tasks.import_archive_recovery import recover_import_archive_publications + async with comicinfo_enrichment_gate(): + await recover_import_archive_publications(session, job_id=job_id) return await _rollback_import_job_while_enrichment_fenced( session, job_id, diff --git a/src/pullbox/tasks/import_archive_recovery.py b/src/pullbox/tasks/import_archive_recovery.py new file mode 100644 index 00000000..085bdff5 --- /dev/null +++ b/src/pullbox/tasks/import_archive_recovery.py @@ -0,0 +1,92 @@ +"""Bounded restart recovery for import-owned archive publications.""" + +from uuid import UUID + +import structlog +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState +from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + _clean_session, + inspect_archive_publication, + load_archive_publication, +) +from pullbox.services.import_archive_recovery import recover_import_archive_publication + +logger = structlog.get_logger(__name__) +RECOVERY_PAGE_SIZE = 32 + + +async def recover_import_archive_publications( + session: AsyncSession, *, job_id: int | None = None +) -> int: + """Own the supplied clean session across short recovery transactions. + + This is called only by startup/enrichment and rollback orchestration, which + already own commits. Release all DB transactions before hashing; never use + elapsed time or a missing worker as authority to republish a stage. + """ + _clean_session(session) + if session.in_nested_transaction(): + raise ArchivePublicationError("recovery_requires_owned_session") + active = (PublicationState.INTENDED, PublicationState.PUBLISHED) + ceiling = await session.scalar( + select(func.max(ArchiveMetadataPublication.id)).where( + ArchiveMetadataPublication.state.in_(active) + ) + ) + if ceiling is None: + return 0 + await session.commit() + cursor = recovered = 0 + while cursor < ceiling: + page = ( + await session.execute( + select(ArchiveMetadataPublication.id, ArchiveMetadataPublication.operation_id) + .where( + ArchiveMetadataPublication.id > cursor, + ArchiveMetadataPublication.id <= ceiling, + ArchiveMetadataPublication.state.in_(active), + ) + .order_by(ArchiveMetadataPublication.id) + .limit(RECOVERY_PAGE_SIZE) + ) + ).all() + await session.commit() + if not page: + break + for row_id, operation in page: + cursor = row_id + try: + try: + operation_id = UUID(operation) + except ValueError: + raise ArchivePublicationError("invalid_journal") from None + receipt = await load_archive_publication(session, operation_id) + await session.commit() + if receipt is None or receipt.plan.import_owner is None: + continue + if job_id is not None and receipt.plan.import_owner.job_id != job_id: + continue + inspection = await inspect_archive_publication(receipt) + result = await recover_import_archive_publication(session, receipt, inspection) + await session.commit() + recovered += int(result != receipt) + logger.info( + "import_archive_publication_recovered", + publication_id=row_id, + job_id=receipt.plan.import_owner.job_id, + outcome=result.state.value, + ) + except (ArchivePublicationError, ArchiveMetadataBindingError, OSError) as exc: + await session.rollback() + reason = "filesystem_unavailable" if isinstance(exc, OSError) else exc.code + logger.warning( + "import_archive_publication_recovery_deferred", + publication_id=row_id, + reason=reason, + ) + return recovered diff --git a/tests/integration/metadata_identity/test_import_archive_recovery.py b/tests/integration/metadata_identity/test_import_archive_recovery.py new file mode 100644 index 00000000..1b06a86b --- /dev/null +++ b/tests/integration/metadata_identity/test_import_archive_recovery.py @@ -0,0 +1,406 @@ +"""Restart/cancellation recovery settles evidence without rewriting archives.""" + +import asyncio +from copy import deepcopy +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import delete, select, update + +from pullbox.models import Issue, LibraryFile, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportJob, + ImportJobAction, + ImportJobActionStatus, + ImportJobStatus, +) +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + inspect_archive_publication, + publish_archive_publication, +) +from pullbox.services.import_archive_recovery import recover_import_archive_publication +from pullbox.services.import_comicinfo_enrichment import run_pending_import_comicinfo_enrichment +from pullbox.services.import_job_actions import rollback_action +from pullbox.services.import_rollback_execution import rollback_import_job +from pullbox.tasks.import_archive_recovery import recover_import_archive_publications +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + load, + prepared, + record, +) +from tests.integration.metadata_identity.test_import_archive_publication import owned, rollback + + +async def recover(factory, receipt): + inspection = await inspect_archive_publication(receipt) + async with factory.begin() as session: + return await recover_import_archive_publication(session, receipt, inspection) + + +async def interrupted(factory, plan, phase): + receipt = await record(factory, plan) + if phase != "original": + async with factory() as session: + published = await publish_archive_publication(session, receipt.operation_id) + if phase == "published": + await session.commit() + receipt = published + else: + await session.rollback() + return receipt + + +@pytest.mark.parametrize("phase", ["original", "rename_gap", "published"]) +@pytest.mark.parametrize("stop", ["none", "cancel", "cancelled", "rollback"]) +async def test_restart_settles_once_and_keeps_original_rollback_proof( + identity_probe_db, tmp_path, phase, stop +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, signature): + receipt = await interrupted(factory, plan, phase) + async with factory.begin() as session: + if stop == "cancel": + await session.execute( + update(ImportJob).values(control_request=ImportControlRequest.CANCEL) + ) + elif stop == "cancelled": + await session.execute(update(ImportJob).values(status=ImportJobStatus.CANCELLED)) + elif stop == "rollback": + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + before = path.read_bytes(), path.stat().st_ino + source_before = source.read_bytes() + result = await recover(factory, receipt) + expected = ( + "abandoned" if phase == "original" else "finalized" if stop == "none" else "settled" + ) + assert result.state.value == expected, ( + "Restart must settle evidence, not leave an active reservation" + ) + async with factory() as session: + row = await session.scalar(select(ArchiveMetadataPublication)) + assert row.active_file_id is None and row.active_path_key is None + action = await session.get(ImportJobAction, ids[2]) + assert action.payload["destination_signature"] == signature + details = (await session.get(ImportedFile, ids[1])).diagnostics["comicinfo_enrichment"] + if phase != "original": + assert action.payload["metadata_publication"] == str(receipt.operation_id) + assert details["status"] == ("complete" if stop == "none" else "cancelled") + assert ( + await session.get(LibraryFile, plan.target.binding.library_file_id) + ).source_signature == signature + assert (path.read_bytes(), path.stat().st_ino) == before + assert await recover(factory, result) == result + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + assert not path.exists() and source.read_bytes() == source_before + + +async def test_cancelled_publication_does_not_overwrite_later_canonical_edits( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + receipt = await interrupted(factory, plan, "published") + async with factory.begin() as session: + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + await session.execute( + update(Series).values(title="Later title", sort_title="Later title") + ) + await session.execute(update(Issue).values(title="Later issue")) + result = await recover(factory, receipt) + assert result.state.value == "settled" + async with factory() as session: + assert (await session.scalar(select(Series))).title == "Later title" + assert (await session.scalar(select(Issue))).title == "Later issue" + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_hash == plan.output_digest and file.file_size == path.stat().st_size + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + + +@pytest.mark.parametrize("change", ["bytes", "action", "registration", "delete"]) +async def test_cancel_does_not_release_changed_or_unowned_successor( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + receipt = await interrupted(factory, plan, "published") + async with factory.begin() as session: + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + if change == "action": + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "destination_path": "different"} + elif change == "registration": + await session.execute(update(LibraryFile).values(issue_id=None)) + elif change == "delete": + await session.execute(delete(ImportJob).where(ImportJob.id == ids[0])) + if change == "bytes": + path.write_bytes(b"user replacement") + before = path.read_bytes() + try: + result = await recover(factory, receipt) + except ArchivePublicationError: + result = await load(factory, receipt.operation_id) + assert result.state.value in {"review", "published"} + async with factory() as session: + row = await session.scalar(select(ArchiveMetadataPublication)) + assert row.active_path_key is not None + assert path.read_bytes() == before + + +async def test_settlement_rolls_back_with_caller_and_retries(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + receipt = await interrupted(factory, plan, "rename_gap") + async with factory.begin() as session: + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + payload = deepcopy((await session.get(ImportJobAction, ids[2])).payload) + inspection = await inspect_archive_publication(receipt) + async with factory() as session: + result = await recover_import_archive_publication(session, receipt, inspection) + assert result.state.value == "settled" + await session.rollback() + assert await load(factory, receipt.operation_id) == receipt + async with factory() as session: + assert (await session.get(ImportJobAction, ids[2])).payload == payload + assert (await recover(factory, receipt)).state.value == "settled" + + +async def test_concurrent_settlement_and_stale_replay_are_idempotent(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + receipt = await interrupted(factory, plan, "rename_gap") + async with factory.begin() as session: + await session.execute( + update(ImportJob).values(control_request=ImportControlRequest.CANCEL) + ) + results = await asyncio.gather(recover(factory, receipt), recover(factory, receipt)) + assert results[0] == results[1] and results[0].state.value == "settled" + async with factory.begin() as session: + await session.execute(delete(ImportJob).where(ImportJob.id == ids[0])) + path.write_bytes(b"later user edit") + assert await recover(factory, receipt) == results[0] + assert path.read_bytes() == b"later user edit" + + +async def test_restart_worker_recovers_only_owned_rows_and_inspects_without_transaction( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.tasks import import_archive_recovery as task + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + receipt = await interrupted(factory, plan, "rename_gap") + async with factory() as session: + + async def inspect_without_transaction(saved): + assert not session.in_transaction(), "Large hashes cannot hold a DB transaction" + return await inspect_archive_publication(saved) + + monkeypatch.setattr( + task, "inspect_archive_publication", inspect_without_transaction, raising=False + ) + assert await recover_import_archive_publications(session, job_id=ids[0] + 1000) == 0 + assert await recover_import_archive_publications(session, job_id=ids[0]) == 1 + assert await recover_import_archive_publications(session) == 0 + assert (await load(factory, receipt.operation_id)).state.value == "finalized" + assert path.exists() + + +async def test_startup_recovery_does_not_send_published_archive_to_legacy_writer( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, _, _): + receipt = await interrupted(factory, plan, "rename_gap") + before = path.read_bytes() + build, apply = AsyncMock(return_value={}), AsyncMock() + await run_pending_import_comicinfo_enrichment( + factory, build_comicinfo_payload=build, apply_comicinfo=apply, log_event=AsyncMock() + ) + assert (await load(factory, receipt.operation_id)).state.value == "finalized" + build.assert_not_awaited() + apply.assert_not_awaited() + assert path.read_bytes() == before + + +async def test_real_rollback_orchestrator_settles_before_removing_successor( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, _): + receipt = await interrupted(factory, plan, "rename_gap") + async with factory.begin() as session: + await session.execute(update(ImportJob).values(status=ImportJobStatus.ROLLING_BACK)) + + async def reverse(session, action): + await rollback_action( + session, + action_id=action.action_id, + action_type=action.action_type, + payload=action.payload, + delete_series=AsyncMock(), + ) + + async with factory() as session: + assert await rollback_import_job( + session, + ids[0], + rollback_action=reverse, + restore_review_state=AsyncMock(), + recompute_series_counters=AsyncMock(), + recompute_file_counters=AsyncMock(), + log_event=AsyncMock(), + emit_progress=AsyncMock(), + estimate_remaining_seconds=lambda *_: None, + job_stats=lambda _: {}, + ) + await session.commit() + async with factory() as session: + assert (await session.get(ImportJob, ids[0])).status is ImportJobStatus.ROLLED_BACK + assert (await load(factory, receipt.operation_id)).state.value == "settled" + assert not path.exists() and source.exists() + + +@pytest.mark.parametrize("change", ["bytes", "canonical", "owner"]) +async def test_unresolved_publication_never_falls_through_to_legacy_rewrite( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + await interrupted(factory, plan, "published") + if change == "bytes": + path.write_bytes(b"user replacement") + else: + async with factory.begin() as session: + if change == "canonical": + await session.execute(update(Issue).values(title="User edit")) + else: + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "original_source_path": "changed"} + before = path.read_bytes() + build, apply = AsyncMock(return_value={}), AsyncMock() + await run_pending_import_comicinfo_enrichment( + factory, build_comicinfo_payload=build, apply_comicinfo=apply, log_event=AsyncMock() + ) + apply.assert_not_awaited() + build.assert_not_awaited() + assert path.read_bytes() == before + async with factory() as session: + row = await session.scalar(select(ArchiveMetadataPublication)) + assert row.active_path_key is not None + details = (await session.get(ImportedFile, ids[1])).diagnostics["comicinfo_enrichment"] + assert details["status"] == "pending" + + +async def test_cancelled_unpublished_work_never_falls_through_to_legacy_rewrite( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, _, _): + receipt = await interrupted(factory, plan, "original") + async with factory.begin() as session: + await session.execute( + update(ImportJob).values(control_request=ImportControlRequest.CANCEL) + ) + before = path.read_bytes() + build, apply = AsyncMock(return_value={}), AsyncMock() + await run_pending_import_comicinfo_enrichment( + factory, build_comicinfo_payload=build, apply_comicinfo=apply, log_event=AsyncMock() + ) + apply.assert_not_awaited() + build.assert_not_awaited() + assert (await load(factory, receipt.operation_id)).state.value == "abandoned" + assert path.read_bytes() == before + + +@pytest.mark.parametrize("invalid", ["plan", "operation_id"]) +async def test_bounded_recovery_continues_past_invalid_journal( + identity_probe_db, tmp_path, monkeypatch, invalid +): + from pullbox.tasks import import_archive_recovery as task + + _, factory, _ = identity_probe_db + async with factory.begin() as session: + session.add( + ArchiveMetadataPublication( + operation_id="00000000-0000-0000-0000-000000000001" + if invalid == "plan" + else "invalid", + active_path_key="a" * 64, + plan_json="{}", + ) + ) + async with owned(factory, tmp_path) as (_, _, plan, _, _): + receipt = await interrupted(factory, plan, "rename_gap") + monkeypatch.setattr(task, "RECOVERY_PAGE_SIZE", 1) + async with factory() as session: + assert await recover_import_archive_publications(session) == 1 + assert (await load(factory, receipt.operation_id)).state.value == "finalized" + async with factory() as session: + invalid = await session.scalar( + select(ArchiveMetadataPublication).order_by(ArchiveMetadataPublication.id) + ) + assert invalid.plan_json == "{}" and invalid.active_path_key == "a" * 64 + + +async def test_recovery_does_not_commit_pending_caller_edits(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, _, _): + receipt = await interrupted(factory, plan, "published") + async with factory() as session: + series = await session.scalar(select(Series)) + series.title = "Pending change" + with pytest.raises(ArchivePublicationError, match="pending_session_changes"): + await recover_import_archive_publications(session) + await session.rollback() + assert await load(factory, receipt.operation_id) == receipt + async with factory() as session: + assert (await session.scalar(select(Series))).title != "Pending change" + + +async def test_recovery_cancellation_during_inspection_is_retryable( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.tasks import import_archive_recovery as task + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, _, _): + receipt = await interrupted(factory, plan, "rename_gap") + before = path.read_bytes() + entered = asyncio.Event() + + async def held(_): + entered.set() + await asyncio.Event().wait() + + async def run(): + async with factory() as session: + await recover_import_archive_publications(session) + + with monkeypatch.context() as patch: + patch.setattr(task, "inspect_archive_publication", held) + worker = asyncio.create_task(run()) + try: + await asyncio.wait_for(entered.wait(), 10) + finally: + worker.cancel() + with pytest.raises(asyncio.CancelledError): + await worker + assert await load(factory, receipt.operation_id) == receipt + async with factory() as session: + assert await recover_import_archive_publications(session) == 1 + assert path.read_bytes() == before + + +async def test_import_recovery_does_not_adopt_an_unowned_publication(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + receipt = await interrupted(factory, plan, "rename_gap") + before = path.read_bytes() + async with factory() as session: + assert await recover_import_archive_publications(session) == 0 + assert await load(factory, receipt.operation_id) == receipt + assert path.read_bytes() == before diff --git a/tests/integration/metadata_identity/test_import_archive_recovery_migration.py b/tests/integration/metadata_identity/test_import_archive_recovery_migration.py new file mode 100644 index 00000000..d6fff8a7 --- /dev/null +++ b/tests/integration/metadata_identity/test_import_archive_recovery_migration.py @@ -0,0 +1,77 @@ +"""Settlement migration preserves intent and forbids erasing successor evidence.""" + +import importlib.util +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, update + +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from tests.integration.metadata_identity.test_archive_finalization_migration import ( + revision as previous, +) +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + load, + prepared, + record, +) +from tests.integration.metadata_identity.test_archive_metadata_publication import ( + revision as original, +) + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py" + ) + spec = importlib.util.spec_from_file_location("archive_settlement_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_settlement_upgrade_preserves_pending_journal(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + table = ArchiveMetadataPublication.__table__ + async with engine.begin() as connection: + await connection.run_sync(table.drop) + await connection.run_sync(lambda conn: original(conn).upgrade()) + await connection.run_sync(lambda conn: previous(conn).upgrade()) + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).upgrade()) + constraints = await connection.run_sync( + lambda conn: inspect(conn).get_check_constraints(table.name) + ) + states = next( + item["sqltext"] + for item in constraints + if item["name"] == "archive_publication_state" + ) + assert "settled" in states, "Stopped publications need retained completion evidence" + assert await load(factory, saved.operation_id) == saved + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + assert await load(factory, saved.operation_id) == saved + + +async def test_settlement_downgrade_refuses_to_erase_evidence(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan): + saved = await record(factory, plan) + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values( + state="settled", active_file_id=None, active_path_key=None + ) + ) + with pytest.raises(RuntimeError, match="settled archive publication"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + assert (await load(factory, saved.operation_id)).state.value == "settled" diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 48df094c..11b1bff8 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["z7t8u9v0w123"] + assert script.get_heads() == ["a8u9v0w1x234"] + assert script.get_revision("a8u9v0w1x234").down_revision == "z7t8u9v0w123" assert script.get_revision("z7t8u9v0w123").down_revision == "y6s7t8u9v012" assert script.get_revision("y6s7t8u9v012").down_revision == "x5r6s7t8u901" assert script.get_revision("x5r6s7t8u901").down_revision == "w4q5r6s7t890" diff --git a/tests/unit/test_import_comicinfo_enrichment.py b/tests/unit/test_import_comicinfo_enrichment.py index bd559fd1..6fa5a4d4 100644 --- a/tests/unit/test_import_comicinfo_enrichment.py +++ b/tests/unit/test_import_comicinfo_enrichment.py @@ -40,6 +40,7 @@ @pytest.mark.asyncio async def test_comicinfo_prefetch_chunks_provider_batches( monkeypatch: pytest.MonkeyPatch, + async_engine, ) -> None: prefetch_issue_metadata = AsyncMock() monkeypatch.setattr(enrichment_module, "COMICVINE_BULK_BATCH_SIZE", 2, raising=False) @@ -61,7 +62,7 @@ async def test_comicinfo_prefetch_chunks_provider_batches( monkeypatch.setattr(enrichment_module, "wait_for_comicinfo_turn", AsyncMock()) await _run_import_comicinfo_enrichment_while_fenced( - AsyncMock(), + async_sessionmaker(async_engine, expire_on_commit=False), job_id=7, build_comicinfo_payload=AsyncMock(), apply_comicinfo=AsyncMock(), @@ -389,6 +390,7 @@ async def test_deferred_enrichment_rejects_reused_library_file_identity( async def test_locked_pending_file_does_not_stop_later_enrichment( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + async_engine, ) -> None: later_archive = tmp_path / "Later Issue.cbz" later_archive.write_text("archive") @@ -458,7 +460,7 @@ async def unused_log_event(*_args: object, **_kwargs: object) -> None: raise AssertionError("completion persistence was replaced for this test") await run_import_comicinfo_enrichment( - object(), # type: ignore[arg-type] + async_sessionmaker(async_engine, expire_on_commit=False), job_id=7, build_comicinfo_payload=unused_build_payload, apply_comicinfo=apply_comicinfo, @@ -472,6 +474,7 @@ async def unused_log_event(*_args: object, **_kwargs: object) -> None: @pytest.mark.asyncio async def test_retryable_provider_error_stops_enrichment_and_leaves_queue_pending( monkeypatch: pytest.MonkeyPatch, + async_engine, ) -> None: async def job_is_completed(_factory: object, *, job_id: int) -> bool: assert job_id == 7 @@ -519,7 +522,7 @@ async def unused_log_event(*_args: object, **_kwargs: object) -> None: raise AssertionError("no durable file event is expected") await run_import_comicinfo_enrichment( - object(), # type: ignore[arg-type] + async_sessionmaker(async_engine, expire_on_commit=False), job_id=7, build_comicinfo_payload=unused_build_payload, apply_comicinfo=should_not_apply, From d1347c2252e01f48a1fd1c714625e0fa58521420 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 07:08:28 -0700 Subject: [PATCH 71/97] fix(metadata): coordinate archive publication with library renames --- docs/development/ARCHITECTURE_OVERVIEW.md | 10 + docs/development/DATABASE_STANDARDS.md | 29 +- src/pullbox/core/file_publication.py | 9 + .../services/archive_metadata_publication.py | 2 + .../services/library_mutation_coordination.py | 112 ++++++ .../services/library_rename_service.py | 144 +++++-- src/pullbox/services/series_service.py | 22 +- tests/e2e/test_story_arc_catalog_page.py | 7 +- .../test_archive_rename_coordination.py | 370 ++++++++++++++++++ tests/integration/test_folder_management.py | 89 ++--- tests/unit/test_library_rename_service.py | 34 +- 11 files changed, 732 insertions(+), 96 deletions(-) create mode 100644 src/pullbox/services/library_mutation_coordination.py create mode 100644 tests/integration/metadata_identity/test_archive_rename_coordination.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 4f16af85..db2195ca 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -596,6 +596,16 @@ update; unresolved reservations block legacy enrichment. Recovery never repeats a filesystem replacement. Shared mutation coordination across other writers, paired enrichment-worker activation and no-op archive-write avoidance remain required. +Short Library browser and series-folder renames now share a database admission +mutex with new publication intent. They reject retained source/stage reservations, +including folder descendants and orphaned file records, instead of relying only +on a reference-file preflight. Library browser rename cancellation joins workers +and commits before releasing coordination; compensation is identity-checked and +never overwrites another destination. Folder path updates treat `%` and `_` as +literal filename characters. Conversion, deletion, bulk utility writers and +other mutation routes have not yet joined this contract. This does not authorize +paired-writer activation or claim crash recovery for arbitrary legacy renames. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 1d0c72f7..6cc1c54b 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -496,7 +496,8 @@ links preserve the immutable intent when the library row is deleted; the path reservation and evidence survive. There is no age-based lease takeover. Services use caller-owned transactions and the shared SQLite write/savepoint -boundary. Import-owned publication first locks its job, imported file and action; +boundary. Recording new intent first acquires the shared file-mutation admission +mutex, before any owner/entity locks. Import-owned publication locks its job, imported file and action; publication then locks source policies, series, issue, root, file, then the journal row. Generic filesystem classification only locks the journal row; import-owner recovery uses the full owner/binding lock order. Large file hashing, rendering @@ -553,6 +554,32 @@ orchestration invoke it under the existing process-local import lane. Shared mutation fencing across all other writers and paired enrichment-worker activation remain required; the process-local lane is not a cross-process filesystem lock. +### Short File Mutation Coordination + +`services/library_mutation_coordination.py` coordinates Library browser renames +and series-folder renames with new archive-publication admission. SQLite acquires +its writer transaction with a no-op ORM update; PostgreSQL uses the fixed +transaction-scoped advisory key pair `0x50554C4C / 0x46494C45`. Acquire this mutex +before entity locks and retain it through the caller's commit or rollback. No +provider calls, archive rewriting or hashing may run under it. Existing bulk +series-folder rename retains its caller-owned transaction; it is not a concurrent +or background archive-conversion lane. + +Under the mutex, renames inspect bounded pages of active publication evidence, +including source and private stage paths and directory descendants. Orphaned +reservations survive deleted LibraryFile rows. Invalid or inconsistent plans fail +closed. A terminal released publication does not block later renames. Publication +work between commits remains protected by its durable reservation, not by a +long-running advisory lock. This is cooperative application coordination, not a +lock against external programs or a durable rename-recovery journal. + +Library browser renames join file work, commit and compensation on cancellation. +Compensation reacquires admission, rechecks reservations/reference protection and +moves back only the owned inode into an unclaimed destination. A completed commit +is not undone when the request is cancelled. Conversion, deletion, bulk utility +writers, import placement and other mutation paths still need the shared contract +before paired background writing can be enabled. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/core/file_publication.py b/src/pullbox/core/file_publication.py index 9ec56132..37df96eb 100644 --- a/src/pullbox/core/file_publication.py +++ b/src/pullbox/core/file_publication.py @@ -16,6 +16,15 @@ _RENAME_EXCL = 4 +def rename_path_without_overwrite(source: Path, target: Path) -> None: + """Atomically claim a rename destination, including for directories.""" + if not stat.S_ISDIR(source.lstat().st_mode): + publish_file_without_overwrite(source, target) + elif not _native_rename_without_overwrite(source, target): + # Directories cannot use the hard-link fallback; plain rename can overwrite. + raise OSError(errno.ENOTSUP, "Storage does not support exclusive directory rename") + + def publish_file_without_overwrite(stage: Path, target: Path) -> None: """Consume a stage using an atomic destination claim, never plain POSIX rename. diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index c32c77d9..e03974be 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -33,6 +33,7 @@ ArchiveMetadataRenderError, render_archive_metadata, ) +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission from pullbox.services.metadata_writer_identity import metadata_write_scope from pullbox.utilities.executors.archive_metadata_staging import StagedArchiveMetadata @@ -274,6 +275,7 @@ async def record_archive_publication( path_key = hashlib.sha256(os.fsencode(plan.target.path)).hexdigest() try: async with metadata_write_scope(session): + await lock_file_mutation_admission(session) await _lock_binding(session, plan) existing = await load_archive_publication(session, operation_id) if existing: diff --git a/src/pullbox/services/library_mutation_coordination.py b/src/pullbox/services/library_mutation_coordination.py new file mode 100644 index 00000000..3485cf6a --- /dev/null +++ b/src/pullbox/services/library_mutation_coordination.py @@ -0,0 +1,112 @@ +"""Serialize short path changes with admission of durable archive publications.""" + +import asyncio +import hashlib +import os +from contextlib import suppress +from pathlib import Path + +from sqlalchemy import case, false, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.models import Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication + +# Two fixed PostgreSQL advisory-lock keys: PULL / FILE. Never derived from user input. +_LOCK_NAMESPACE = 0x50554C4C +_LOCK_RESOURCE = 0x46494C45 +_MAX_PLAN_BYTES = 4 * 1024 * 1024 +_PAGE_SIZE = 8 + + +async def finish_short_mutation[T](task: asyncio.Task[T]) -> T: + """Join short rename/commit/cleanup work before cancellation releases its mutex.""" + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + while not task.done(): + with suppress(asyncio.CancelledError, Exception): + await asyncio.shield(task) + if not task.cancelled(): + task.exception() + raise + + +async def lock_file_mutation_admission(session: AsyncSession) -> None: + """Hold the admission mutex until the caller commits or rolls back. + + Acquire before entity locks. Only short path changes and publication admission + belong here, never archive reads, conversion, hashing or provider requests. + Durable publication reservations protect the longer interval between commits. + """ + dialect = session.get_bind().dialect.name + if dialect == "sqlite": + await session.execute(update(Series).where(false()).values(path=Series.path)) + elif dialect == "postgresql": + await session.execute(select(func.pg_advisory_xact_lock(_LOCK_NAMESPACE, _LOCK_RESOURCE))) + else: + raise ValidationError("This database cannot safely coordinate library file changes.") + + +async def require_no_archive_publication( + session: AsyncSession, *paths: Path, include_descendants: bool +) -> None: + """Check retained source/stage reservations under the admission mutex. + + Do not depend on live LibraryFile rows: their deletion deliberately leaves + publication evidence behind. Bound each page and fail closed on corrupt plans. + """ + from pullbox.services.archive_metadata_publication import ArchivePublicationPlan + + targets = {variant for path in paths for variant in (path.absolute(), path.resolve())} + last_id = 0 + while True: + rows = ( + await session.execute( + select( + ArchiveMetadataPublication.id, + ArchiveMetadataPublication.active_path_key, + case( + ( + func.length(ArchiveMetadataPublication.plan_json) <= _MAX_PLAN_BYTES, + ArchiveMetadataPublication.plan_json, + ), + else_=None, + ), + ) + .where( + ArchiveMetadataPublication.id > last_id, + ArchiveMetadataPublication.active_path_key.is_not(None), + ) + .order_by(ArchiveMetadataPublication.id) + .limit(_PAGE_SIZE) + ) + ).all() + if not rows: + return + for row_id, path_key, encoded in rows: + last_id = row_id + try: + if encoded is None or len(encoded.encode("utf-8")) > _MAX_PLAN_BYTES: + raise ValueError("Invalid publication plan") + plan = ArchivePublicationPlan.model_validate_json(encoded) + if ( + not plan.target.path.is_absolute() + or not plan.stage_path.is_absolute() + or hashlib.sha256(os.fsencode(plan.target.path)).hexdigest() != path_key + ): + raise ValueError("Changed publication plan") + except ValueError: + raise ValidationError( + "A pending metadata update needs recovery before library files can be renamed." + ) from None + for reserved in (plan.target.path, plan.stage_path): + if any( + reserved == target or (include_descendants and reserved.is_relative_to(target)) + for target in targets + ): + raise ValidationError( + "A metadata update is still pending for this file or folder. " + "Finish or recover that update, then retry the rename." + ) diff --git a/src/pullbox/services/library_rename_service.py b/src/pullbox/services/library_rename_service.py index 06e8c765..3d4846e3 100644 --- a/src/pullbox/services/library_rename_service.py +++ b/src/pullbox/services/library_rename_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os from dataclasses import dataclass from datetime import UTC, datetime @@ -12,9 +13,15 @@ from sqlalchemy import or_, select from pullbox.core.exceptions import ValidationError +from pullbox.core.file_publication import rename_path_without_overwrite from pullbox.core.library_file_ownership import require_mutable_library_target from pullbox.models.library import LibraryFile from pullbox.models.series import Series +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, + require_no_archive_publication, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -40,13 +47,21 @@ def _rename_path(source: Path, target: Path) -> None: if not is_case_only and target.exists(): raise ValidationError("A file or folder with that name already exists.") - if is_case_only: - temp_path = source.parent / f".rename_tmp_{os.urandom(8).hex()}" - source.rename(temp_path) - temp_path.rename(target) - return - - source.rename(target) + try: + if is_case_only and target.exists(): + if not source.samefile(target): + raise FileExistsError + temp_path = source.parent / f".rename_tmp_{os.urandom(8).hex()}" + rename_path_without_overwrite(source, temp_path) + try: + rename_path_without_overwrite(temp_path, target) + except OSError: + rename_path_without_overwrite(temp_path, source) + raise + else: + rename_path_without_overwrite(source, target) + except FileExistsError: + raise ValidationError("A file or folder with that name already exists.") from None async def _sync_file_record(session: AsyncSession, *, before_path: str, after_path: str) -> None: @@ -69,7 +84,11 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after new_prefix = after_path.rstrip("/") series_result = await session.execute( - select(Series).where(or_(Series.path == old_prefix, Series.path.like(f"{old_prefix}/%"))) + select(Series).where( + or_( + Series.path == old_prefix, Series.path.startswith(f"{old_prefix}/", autoescape=True) + ) + ) ) for series in series_result.scalars().all(): if not series.path: @@ -79,7 +98,10 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after file_result = await session.execute( select(LibraryFile).where( - or_(LibraryFile.file_path == old_prefix, LibraryFile.file_path.like(f"{old_prefix}/%")) + or_( + LibraryFile.file_path == old_prefix, + LibraryFile.file_path.startswith(f"{old_prefix}/", autoescape=True), + ) ) ) for library_file in file_result.scalars().all(): @@ -91,6 +113,29 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after library_file.file_name = Path(next_path).name +async def _destination_is_registered(session: AsyncSession, target: Path, kind: str) -> bool: + prefix = str(target).rstrip("/") + file_clause = LibraryFile.file_path == prefix + if kind == "folder": + file_clause |= LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True) + if await session.scalar(select(LibraryFile.id).where(file_clause).limit(1)) is not None: + return True + if kind == "folder": + return ( + await session.scalar( + select(Series.id) + .where( + or_( + Series.path == prefix, Series.path.startswith(f"{prefix}/", autoescape=True) + ) + ) + .limit(1) + ) + is not None + ) + return False + + async def rename_library_entry( session: AsyncSession, *, @@ -99,46 +144,85 @@ async def rename_library_entry( kind: str, ) -> LibraryRenameOutcome: """Rename a single Library browser target and sync tracked DB paths.""" - renamed = False + renamed_identity: tuple[int, int] | None = None + committed = False + + def rename() -> None: + nonlocal renamed_identity + info = source.lstat() + _rename_path(source, target) + renamed_identity = info.st_dev, info.st_ino + + async def commit() -> None: + nonlocal committed + await session.commit() + committed = True + + async def restore() -> bool: + await session.rollback() + if renamed_identity is None or committed: + return False + try: + # Rollback may have released DB locks. Reacquire before compensation. + await lock_file_mutation_admission(session) + # A lost commit acknowledgment is not proof that the commit failed. + if await _destination_is_registered(session, target, kind): + return False + await require_no_archive_publication( + session, source, target, include_descendants=kind == "folder" + ) + await require_mutable_library_target( + session, target, include_descendants=kind == "folder", operation="renamed" + ) + + def restore_owned_path() -> bool: + if source.exists() or not target.exists(): + return False + info = target.lstat() + if (info.st_dev, info.st_ino) != renamed_identity: + return False + _rename_path(target, source) + return True + + return await finish_short_mutation( + asyncio.create_task(asyncio.to_thread(restore_owned_path)) + ) + except Exception: + logger.exception( + "library_rename_rollback_failed", source_path=str(source), target_path=str(target) + ) + return False + finally: + await session.rollback() try: + await lock_file_mutation_admission(session) + await require_no_archive_publication( + session, source, target, include_descendants=kind == "folder" + ) await require_mutable_library_target( session, source, include_descendants=kind == "folder", operation="renamed", ) - _rename_path(source, target) - renamed = True + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(rename))) if kind == "file": await _sync_file_record(session, before_path=str(source), after_path=str(target)) else: await _sync_folder_records(session, before_path=str(source), after_path=str(target)) - await session.commit() + await finish_short_mutation(asyncio.create_task(commit())) return LibraryRenameOutcome( kind=kind, source_path=str(source), target_path=str(target), ) - except ValidationError: - await session.rollback() - raise - except Exception as exc: - await session.rollback() - restored = False - if renamed and target.exists() and not source.exists(): - try: - _rename_path(target, source) - restored = True - except Exception: - logger.exception( - "library_rename_rollback_failed", - source_path=str(source), - target_path=str(target), - ) - + except BaseException as exc: + restored = await finish_short_mutation(asyncio.create_task(restore())) + if isinstance(exc, ValidationError) or not isinstance(exc, Exception): + raise message = "Rename could not be completed." if restored: message += " The original name was restored." diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py index 9a2bee38..8682b2a8 100644 --- a/src/pullbox/services/series_service.py +++ b/src/pullbox/services/series_service.py @@ -14,6 +14,7 @@ from pullbox.core.events import EventBus, IssueWanted, SeriesAdded from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.file_publication import rename_path_without_overwrite from pullbox.core.library_file_ownership import ( referenced_library_files_for_target, require_mutable_library_target, @@ -41,6 +42,11 @@ find_imported_series_cover, purge_series_cover_cache, ) +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, + require_no_archive_publication, +) from pullbox.services.library_root_management import validate_managed_library_root from pullbox.services.metadata_writer_identity import ImportIdentityOrigin from pullbox.services.series_delete_targets import ( @@ -678,6 +684,7 @@ async def rename_series_folder( Returns: The new path if renamed, or ``None`` if no rename was needed. """ + await lock_file_mutation_admission(session) series = await session.get(Series, series_id) if not series or not series.path or not series.library_root_id: return None @@ -703,6 +710,13 @@ async def rename_series_folder( if current_path == expected_path: return None + await require_no_archive_publication( + session, + current_path, + expected_path, + expected_path.with_name(f"{expected_name} [cv-{series.comicvine_id}]"), + include_descendants=True, + ) await require_mutable_library_target( session, current_path, @@ -736,8 +750,12 @@ async def rename_series_folder( # Rename on disk if current_path.is_dir(): try: - await asyncio.to_thread(expected_path.parent.mkdir, parents=True, exist_ok=True) - await asyncio.to_thread(current_path.rename, expected_path) + + def rename() -> None: + expected_path.parent.mkdir(parents=True, exist_ok=True) + rename_path_without_overwrite(current_path, expected_path) + + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(rename))) series.path = str(expected_path) logger.info( "series_folder_renamed", diff --git a/tests/e2e/test_story_arc_catalog_page.py b/tests/e2e/test_story_arc_catalog_page.py index 6ec94bdd..aba7ade8 100644 --- a/tests/e2e/test_story_arc_catalog_page.py +++ b/tests/e2e/test_story_arc_catalog_page.py @@ -419,7 +419,12 @@ def test_keyboard_catalog_add_and_refresh_preserve_reviewed_order( expect(page.locator("[data-membership-id]").nth(0)).to_have_attribute( "data-exact-issue-number", "1AU" ) - page.get_by_role("button", name="Review issue 2 match").click() + # URL/rows appear before the boosted navigation's settle and scroll reset. + expect(page.locator("#content .htmx-added, #content.htmx-settling")).to_have_count(0) + review_toggle = page.get_by_role("button", name="Review issue 2 match") + expect(review_toggle).to_have_attribute("aria-expanded", "false") + review_toggle.click() + expect(review_toggle).to_have_attribute("aria-expanded", "true") expect(page.get_by_role("button", name="Confirm reading order")).to_be_visible() expect(page.get_by_role("button", name="Search local issues")).to_have_count(0) page.get_by_role("button", name="Confirm reading order").click() diff --git a/tests/integration/metadata_identity/test_archive_rename_coordination.py b/tests/integration/metadata_identity/test_archive_rename_coordination.py new file mode 100644 index 00000000..cec651de --- /dev/null +++ b/tests/integration/metadata_identity/test_archive_rename_coordination.py @@ -0,0 +1,370 @@ +"""Real Library renames must coordinate with durable metadata publications.""" + +import asyncio +import threading +from dataclasses import replace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import delete, update + +from pullbox.core.events import EventBus +from pullbox.core.exceptions import ValidationError +from pullbox.models import LibraryFile, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState +from pullbox.services import archive_metadata_publication as publications +from pullbox.services import library_rename_service as renames +from pullbox.services.archive_metadata_binding import ( + ArchiveMetadataBindingError, + read_archive_metadata_binding, +) +from pullbox.services.archive_metadata_publication import publish_archive_publication +from pullbox.services.series_service import SeriesService +from tests.integration.metadata_identity.test_archive_metadata_publication import prepared, record + + +@pytest.mark.parametrize("state", ["intended", "published", "review"]) +@pytest.mark.parametrize("kind", ["file", "folder"]) +async def test_active_publication_prevents_library_rename(identity_probe_db, tmp_path, state, kind): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + receipt = await record(factory, plan) + async with factory.begin() as session: + if state == "published": + await publish_archive_publication(session, receipt.operation_id) + elif state == "review": + await session.execute( + update(ArchiveMetadataPublication).values(state=PublicationState.REVIEW) + ) + before = path.read_bytes() + source = path if kind == "file" else path.parent + target = source.with_name("renamed") + async with factory() as session: + with pytest.raises(ValidationError, match="metadata update"): + await renames.rename_library_entry(session, source=source, target=target, kind=kind) + assert path.read_bytes() == before + assert not target.exists() + + +@pytest.mark.parametrize( + "variant", ["orphan", "alias", "stage", "destination", "invalid", "changed_plan"] +) +async def test_reservations_protect_paths_not_only_live_library_rows( + identity_probe_db, tmp_path, variant +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, stage, plan): + await record(factory, plan) + source, target = path, path.with_name("renamed.cbz") + if variant == "orphan": + async with factory.begin() as session: + await session.execute(delete(LibraryFile)) + elif variant == "alias": + alias = tmp_path / "alias" + alias.symlink_to(path.parent, target_is_directory=True) + source, target = alias / path.name, alias / "renamed.cbz" + elif variant == "stage": + source, target = stage.path, stage.path.with_name("renamed.cbz") + elif variant == "destination": + source, target = path.with_name("other.cbz"), path + source.write_bytes(b"another file") + elif variant == "invalid": + async with factory.begin() as session: + await session.execute(update(ArchiveMetadataPublication).values(plan_json="{}")) + elif variant == "changed_plan": + changed = plan.model_copy( + update={"target": replace(plan.target, path=path.with_name("other.cbz"))} + ) + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values(plan_json=changed.model_dump_json()) + ) + original = source.read_bytes() + async with factory() as session: + with pytest.raises(ValidationError, match="metadata update"): + await renames.rename_library_entry( + session, source=source, target=target, kind="file" + ) + assert source.read_bytes() == original + + +async def test_unrelated_rename_and_released_publication_remain_available( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + await record(factory, plan) + unrelated = path.with_name("unrelated.cbz") + unrelated.write_bytes(b"independent") + async with factory() as session: + await renames.rename_library_entry( + session, source=unrelated, target=unrelated.with_name("new.cbz"), kind="file" + ) + async with factory.begin() as session: + await session.execute( + update(ArchiveMetadataPublication).values( + state=PublicationState.ABANDONED, active_file_id=None, active_path_key=None + ) + ) + target = path.with_name("renamed.cbz") + async with factory() as session: + await renames.rename_library_entry(session, source=path, target=target, kind="file") + assert target.exists() and not path.exists() + async with factory() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_path == str(target) + + +async def test_rename_admission_and_new_publication_cannot_race( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + entered, release = asyncio.Event(), asyncio.Event() + sync = renames._sync_file_record + + async def pause_before_database_sync(*args, **kwargs): + entered.set() + await asyncio.wait_for(release.wait(), 10) + await sync(*args, **kwargs) + + monkeypatch.setattr(renames, "_sync_file_record", pause_before_database_sync) + target = path.with_name("renamed.cbz") + async with factory() as session: + rename = asyncio.create_task( + renames.rename_library_entry(session, source=path, target=target, kind="file") + ) + await asyncio.wait_for(entered.wait(), 10) + publish = asyncio.create_task(record(factory, plan)) + try: + await asyncio.sleep(0.1) + assert not publish.done(), "Publication must wait for the rename transaction" + finally: + release.set() + await asyncio.wait_for(rename, 10) + result = await asyncio.gather(publish, return_exceptions=True) + assert isinstance(result[0], ArchiveMetadataBindingError) + assert target.exists() and not path.exists() + + +async def test_folder_rename_updates_only_literal_descendants(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + source = tmp_path / "Comics_100%" + source.mkdir() + sibling = tmp_path / "ComicsX100percent" + sibling.mkdir() + async with factory.begin() as session: + moved = Series(title="Moved", sort_title="Moved", path=str(source / "inside")) + untouched = Series(title="Untouched", sort_title="Untouched", path=str(sibling / "inside")) + session.add_all([moved, untouched]) + await session.flush() + moved_id, untouched_id = moved.id, untouched.id + target = source.with_name("Renamed") + async with factory() as session: + await renames.rename_library_entry(session, source=source, target=target, kind="folder") + async with factory() as session: + assert (await session.get(Series, moved_id)).path == str(target / "inside") + assert (await session.get(Series, untouched_id)).path == str(sibling / "inside") + + +async def test_inflight_publication_admission_blocks_rename( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + entered, release = asyncio.Event(), asyncio.Event() + lock = publications._lock_binding + + async def pause_after_admission(*args, **kwargs): + entered.set() + await asyncio.wait_for(release.wait(), 10) + await lock(*args, **kwargs) + + monkeypatch.setattr(publications, "_lock_binding", pause_after_admission) + publish = asyncio.create_task(record(factory, plan)) + await asyncio.wait_for(entered.wait(), 10) + async with factory() as session: + rename = asyncio.create_task( + renames.rename_library_entry( + session, source=path, target=path.with_name("renamed.cbz"), kind="file" + ) + ) + try: + await asyncio.sleep(0.1) + assert not rename.done(), "Rename must wait for the publication admission" + finally: + release.set() + await asyncio.wait_for(publish, 10) + result = await asyncio.gather(rename, return_exceptions=True) + assert isinstance(result[0], ValidationError) + assert path.exists() + + +async def test_series_folder_rename_also_respects_publication(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + series_id = plan.target.binding.metadata.series.local_id + async with factory.begin() as session: + series = await session.get(Series, series_id) + series.path = str(path.parent) + series.library_root_id = plan.target.binding.library_root_id + # Re-read the binding after the explicit series-path edit. + async with factory() as session: + bound = await read_archive_metadata_binding( + session, plan.target.binding.library_file_id + ) + plan = plan.model_copy(update={"target": replace(plan.target, binding=bound)}) + await record(factory, plan) + async with factory() as session: + with pytest.raises(ValidationError, match="metadata update"): + await SeriesService(AsyncMock(), EventBus()).rename_series_folder( + session, series_id + ) + assert path.exists() + + +async def test_cancelled_rename_restores_file_and_registration( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + entered = asyncio.Event() + + async def pause_before_database_sync(*args, **kwargs): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(renames, "_sync_file_record", pause_before_database_sync) + original = path.read_bytes() + target = path.with_name("renamed.cbz") + async with factory() as session: + task = asyncio.create_task( + renames.rename_library_entry(session, source=path, target=target, kind="file") + ) + await asyncio.wait_for(entered.wait(), 10) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert path.exists(), "Cancellation must not strand the renamed file" + assert path.read_bytes() == original and not target.exists() + async with factory() as session: + assert ( + await session.get(LibraryFile, plan.target.binding.library_file_id) + ).file_path == str(path) + + +async def test_failed_rename_does_not_restore_someone_elses_replacement( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, _): + target = path.with_name("renamed.cbz") + saved_original = path.with_name("original.cbz") + + async def fail_after_external_replacement(*args, **kwargs): + target.rename(saved_original) + target.write_bytes(b"someone else's file") + raise RuntimeError("database write failed") + + monkeypatch.setattr(renames, "_sync_file_record", fail_after_external_replacement) + async with factory() as session: + with pytest.raises(ValidationError, match="could not be completed"): + await renames.rename_library_entry(session, source=path, target=target, kind="file") + assert target.exists(), "Rollback must not move a replacement it does not own" + assert target.read_bytes() == b"someone else's file" and not path.exists() + + +async def test_cancel_joins_slow_rename_before_releasing_coordination( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, _): + entered, release = threading.Event(), threading.Event() + original = renames._rename_path + + def slow_rename(*args): + entered.set() + assert release.wait(10) + original(*args) + + monkeypatch.setattr(renames, "_rename_path", slow_rename) + target = path.with_name("renamed.cbz") + async with factory() as session: + task = asyncio.create_task( + renames.rename_library_entry(session, source=path, target=target, kind="file") + ) + assert await asyncio.to_thread(entered.wait, 10) + try: + task.cancel() + await asyncio.sleep(0.05) + task.cancel() + await asyncio.sleep(0.05) + assert not task.done(), "The filesystem worker still owns the rename boundary" + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 10) + assert path.exists() and not target.exists() + + +async def test_cancel_during_commit_does_not_undo_a_committed_rename( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + entered, release = asyncio.Event(), asyncio.Event() + target = path.with_name("renamed.cbz") + async with factory() as session: + original_commit = session.commit + + async def slow_commit(): + entered.set() + await asyncio.wait_for(release.wait(), 10) + await original_commit() + + monkeypatch.setattr(session, "commit", slow_commit) + task = asyncio.create_task( + renames.rename_library_entry(session, source=path, target=target, kind="file") + ) + await asyncio.wait_for(entered.wait(), 10) + try: + task.cancel() + await asyncio.sleep(0.05) + task.cancel() + await asyncio.sleep(0.05) + assert not task.done() + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 10) + assert target.exists() and not path.exists() + async with factory() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_path == str(target) + + +@pytest.mark.parametrize("kind", ["file", "folder"]) +async def test_lost_commit_acknowledgment_does_not_undo_registered_rename( + identity_probe_db, tmp_path, monkeypatch, kind +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (path, _, plan): + source = path if kind == "file" else path.parent + target = source.with_name("renamed") + destination_file = target if kind == "file" else target / path.name + original = path.read_bytes() + async with factory() as session: + original_commit = session.commit + + async def committed_without_acknowledgment(): + await original_commit() + raise RuntimeError("connection lost after commit") + + monkeypatch.setattr(session, "commit", committed_without_acknowledgment) + with pytest.raises(ValidationError, match="could not be completed"): + await renames.rename_library_entry(session, source=source, target=target, kind=kind) + assert destination_file.exists(), "A committed rename must not be compensated" + assert destination_file.read_bytes() == original and not source.exists() + async with factory() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + assert file.file_path == str(destination_file) diff --git a/tests/integration/test_folder_management.py b/tests/integration/test_folder_management.py index 632dd26e..fbc37a2a 100644 --- a/tests/integration/test_folder_management.py +++ b/tests/integration/test_folder_management.py @@ -128,13 +128,12 @@ def series_svc(self): ) @pytest.mark.asyncio - async def test_rename_when_template_changes(self, tmp_path, series_svc): + async def test_rename_when_template_changes(self, tmp_path, series_svc, db_session): """Folder is renamed when template produces a different name.""" # Current folder old_folder = tmp_path / "Batman (2024)" old_folder.mkdir() - # Mock series series = Series( id=1, title="Batman", @@ -145,9 +144,9 @@ async def test_rename_when_template_changes(self, tmp_path, series_svc): library_root_id=1, ) - # Mock library root - root = MagicMock() - root.path = str(tmp_path) + root = LibraryRoot(id=1, name="Comics", path=str(tmp_path)) + db_session.add_all([root, series]) + await db_session.flush() # Mock config — new template includes publisher config_data = { @@ -156,25 +155,14 @@ async def test_rename_when_template_changes(self, tmp_path, series_svc): "colon_replacement": "dash", } - session = AsyncMock() - session.get = AsyncMock( - side_effect=lambda cls, id: series if cls is not LibraryRoot else root, - ) - # Mock _load_naming_config - with ( - patch.object( - series_svc.__class__, - "_load_naming_config", - new_callable=AsyncMock, - return_value=config_data, - ), - patch( - "pullbox.services.series_service.require_mutable_library_target", - new_callable=AsyncMock, - ), + with patch.object( + series_svc.__class__, + "_load_naming_config", + new_callable=AsyncMock, + return_value=config_data, ): - new_path = await series_svc.rename_series_folder(session, 1) + new_path = await series_svc.rename_series_folder(db_session, 1) assert new_path is not None assert "Unknown" in new_path # publisher is None → "Unknown" @@ -182,7 +170,7 @@ async def test_rename_when_template_changes(self, tmp_path, series_svc): assert Path(new_path).exists() @pytest.mark.asyncio - async def test_no_rename_when_already_correct(self, tmp_path, series_svc): + async def test_no_rename_when_already_correct(self, tmp_path, series_svc, db_session): """No rename happens when folder already matches template.""" folder = tmp_path / "Batman (2024)" folder.mkdir() @@ -197,8 +185,9 @@ async def test_no_rename_when_already_correct(self, tmp_path, series_svc): library_root_id=1, ) - root = MagicMock() - root.path = str(tmp_path) + root = LibraryRoot(id=1, name="Comics", path=str(tmp_path)) + db_session.add_all([root, series]) + await db_session.flush() config_data = { "series_folder_template": "{Series} ({Year})", @@ -206,37 +195,29 @@ async def test_no_rename_when_already_correct(self, tmp_path, series_svc): "colon_replacement": "dash", } - session = AsyncMock() - session.get = AsyncMock( - side_effect=lambda cls, id: series if cls is not LibraryRoot else root, - ) - with patch.object( series_svc.__class__, "_load_naming_config", new_callable=AsyncMock, return_value=config_data, ): - new_path = await series_svc.rename_series_folder(session, 1) + new_path = await series_svc.rename_series_folder(db_session, 1) assert new_path is None assert folder.exists() @pytest.mark.asyncio - async def test_returns_none_for_series_without_path(self, series_svc): + async def test_returns_none_for_series_without_path(self, series_svc, db_session): """Series without a path returns None.""" - series = MagicMock() - series.path = None - series.library_root_id = None + series = Series(id=1, title="Batman", sort_title="Batman") + db_session.add(series) + await db_session.flush() - session = AsyncMock() - session.get = AsyncMock(return_value=series) - - result = await series_svc.rename_series_folder(session, 1) + result = await series_svc.rename_series_folder(db_session, 1) assert result is None @pytest.mark.asyncio - async def test_collision_appends_cv_id(self, tmp_path, series_svc): + async def test_collision_appends_cv_id(self, tmp_path, series_svc, db_session): """When target folder exists, appends [cv-{id}].""" old_folder = tmp_path / "Old Name (2024)" old_folder.mkdir() @@ -255,8 +236,9 @@ async def test_collision_appends_cv_id(self, tmp_path, series_svc): library_root_id=1, ) - root = MagicMock() - root.path = str(tmp_path) + root = LibraryRoot(id=1, name="Comics", path=str(tmp_path)) + db_session.add_all([root, series]) + await db_session.flush() config_data = { "series_folder_template": "{Series} ({Year})", @@ -264,24 +246,13 @@ async def test_collision_appends_cv_id(self, tmp_path, series_svc): "colon_replacement": "dash", } - session = AsyncMock() - session.get = AsyncMock( - side_effect=lambda cls, id: series if cls is not LibraryRoot else root, - ) - - with ( - patch.object( - series_svc.__class__, - "_load_naming_config", - new_callable=AsyncMock, - return_value=config_data, - ), - patch( - "pullbox.services.series_service.require_mutable_library_target", - new_callable=AsyncMock, - ), + with patch.object( + series_svc.__class__, + "_load_naming_config", + new_callable=AsyncMock, + return_value=config_data, ): - new_path = await series_svc.rename_series_folder(session, 1) + new_path = await series_svc.rename_series_folder(db_session, 1) assert new_path is not None assert "[cv-99999]" in new_path diff --git a/tests/unit/test_library_rename_service.py b/tests/unit/test_library_rename_service.py index 98f30b7f..2ed19f1a 100644 --- a/tests/unit/test_library_rename_service.py +++ b/tests/unit/test_library_rename_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -14,11 +15,9 @@ LibraryFileStorageMode, LibraryRoot, ) -from pullbox.services.library_rename_service import rename_library_entry +from pullbox.services.library_rename_service import _rename_path, rename_library_entry if TYPE_CHECKING: - from pathlib import Path - from sqlalchemy.ext.asyncio import AsyncSession @@ -63,3 +62,32 @@ async def test_rename_rejects_target_containing_referenced_file( assert source_file.read_bytes() == original assert not target.exists() + + +@pytest.mark.parametrize("kind", ["file", "folder"]) +def test_rename_never_overwrites_a_racing_destination(tmp_path, monkeypatch, kind): + source, target = tmp_path / "source", tmp_path / "target" + if kind == "folder": + source.mkdir() + else: + source.write_bytes(b"original") + exists = Path.exists + raced = False + + def create_after_check(path): + nonlocal raced + if path == target and not raced: + raced = True + if kind == "folder": + target.mkdir() + else: + target.write_bytes(b"other") + return False + return exists(path) + + monkeypatch.setattr(Path, "exists", create_after_check) + with pytest.raises(ValidationError, match="already exists"): + _rename_path(source, target) + assert source.exists() and target.exists() + if kind == "file": + assert source.read_bytes() == b"original" and target.read_bytes() == b"other" From 06efc109ea05f7f1b778f27b3b73b77ca2949194 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 08:05:22 -0700 Subject: [PATCH 72/97] feat(metadata): stage paired metadata from non-ZIP archives --- docs/development/ARCHITECTURE_OVERVIEW.md | 12 +- docs/development/SECURITY_STANDARDS.md | 5 +- src/pullbox/core/archive_metadata.py | 14 +- src/pullbox/core/metadata_archive_source.py | 280 +++++++++++++++++ .../services/archive_metadata_writing.py | 24 +- .../executors/archive_metadata_staging.py | 2 +- tests/unit/test_nonzip_metadata_writing.py | 295 ++++++++++++++++++ 7 files changed, 617 insertions(+), 15 deletions(-) create mode 100644 src/pullbox/core/metadata_archive_source.py create mode 100644 tests/unit/test_nonzip_metadata_writing.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index db2195ca..c160b417 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -512,7 +512,7 @@ legacy writers have not switched to this boundary yet. `services/archive_metadata_writing.py` provides the coordinated CBZ publication primitive for independently authorized callers. It reads both XML members from -one source ZIP session, renders the pair, streams approved entries into one +one source archive session, renders the pair, streams approved entries into one same-directory temporary archive, verifies its manifest and payload CRCs, and publishes without overwriting an existing destination. Explicit same-path refresh uses atomic replacement after closing the source handle. Source/staging @@ -521,8 +521,14 @@ serialization, binding, managed-root authorization, rollback journals and interruptible process cleanup. Cooperative cancellation is checked through streaming, verification and immediately before publication, never after a successful commit point. Reference-only/keep-in-place callers are not authorized -by this primitive. Production caller migration and single-construction non-ZIP -conversion remain outstanding. +by this primitive. `core/metadata_archive_source.py` also accepts RAR, 7z and TAR +sources without an intermediate CBZ. RAR/TAR members are streamed; solid 7z is +decompressed once into bounded, private numbered spools under the owner's staging +directory, never archive-supplied filesystem paths. Special/encrypted/split +members and unsafe or oversized payloads fail closed. Cancellation checks cover +extraction, copying and verification. Production conversion caller migration, +cross-filesystem publication/trash recovery and paired PDF conversion remain +outstanding; legacy converters have not switched to this boundary. `utilities/executors/archive_metadata_staging.py` hands paired CBZ work to the interruptible archive worker without giving it a final library destination. diff --git a/docs/development/SECURITY_STANDARDS.md b/docs/development/SECURITY_STANDARDS.md index c72d1fbd..43b44052 100644 --- a/docs/development/SECURITY_STANDARDS.md +++ b/docs/development/SECURITY_STANDARDS.md @@ -533,7 +533,10 @@ that lock down important behavior. - CBZ repack plus CBR/CB7 conversion reject unsafe archive member names before extraction. - Non-ZIP archive families do not receive equivalent decompressed-size - inspection today. + inspection in legacy conversion paths. The paired metadata writer separately + enforces declared and streamed limits for RAR/7z/TAR, rejects links/special + members and uses private numbered 7z spools instead of extracted member paths. + Its production consumer migration is not yet enabled. **Required standard** diff --git a/src/pullbox/core/archive_metadata.py b/src/pullbox/core/archive_metadata.py index d1c35e9e..96998972 100644 --- a/src/pullbox/core/archive_metadata.py +++ b/src/pullbox/core/archive_metadata.py @@ -176,6 +176,14 @@ def _read_members[T]( def read_open_zip_metadata(archive: zipfile.ZipFile) -> ArchiveMetadataFiles: """Reuse an already-open ZIP inspection without reopening or extracting it.""" + return read_open_metadata_members(archive.infolist(), lambda member: archive.open(member, "r")) + + +def read_open_metadata_members( + members: Sequence[zipfile.ZipInfo], + open_member: Callable[[zipfile.ZipInfo], AbstractContextManager[IO[bytes]]], +) -> ArchiveMetadataFiles: + """Read both documents from validated, normalized archive member descriptors.""" candidates = [ _Candidate( member, @@ -185,11 +193,9 @@ def read_open_zip_metadata(archive: zipfile.ZipFile) -> ArchiveMetadataFiles: not member.is_dir() and (S_IFMT(member.external_attr >> 16) == 0 or S_ISREG(member.external_attr >> 16)), ) - for member in archive.infolist() + for member in members ] - return _read_members( - candidates, lambda member: archive.open(member, "r"), (zipfile.BadZipFile,) - ) + return _read_members(candidates, open_member, (zipfile.BadZipFile,)) def _read_rar(path: Path, max_solid_scan_bytes: int) -> ArchiveMetadataFiles: diff --git a/src/pullbox/core/metadata_archive_source.py b/src/pullbox/core/metadata_archive_source.py new file mode 100644 index 00000000..4b191f22 --- /dev/null +++ b/src/pullbox/core/metadata_archive_source.py @@ -0,0 +1,280 @@ +"""Bounded archive inputs for single-construction paired CBZ conversion.""" + +import io +import os +import stat +import tarfile +import tempfile +import threading +import zipfile +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, ExitStack, contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import IO, BinaryIO + +from py7zr.io import Py7zIO, WriterFactory + +from pullbox.core.file_safety import FileSafetyError + + +@dataclass +class MetadataArchiveSource: + entries: list[zipfile.ZipInfo] + open: Callable[[zipfile.ZipInfo], AbstractContextManager[IO[bytes]]] + close: Callable[[], None] + comment: bytes = b"" + + +def _entry(name: str, size: int, directory: bool = False) -> zipfile.ZipInfo: + if "\x00" in name: + raise FileSafetyError("Archive contains an unsafe member name") + entry = zipfile.ZipInfo(name + ("/" if directory and not name.endswith("/") else "")) + entry.file_size = size + entry.compress_size = 0 + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = ((stat.S_IFDIR | 0o755) if directory else (stat.S_IFREG | 0o644)) << 16 + return entry + + +@contextmanager +def open_metadata_archive( + stream: BinaryIO, + path: Path, + *, + limit: int, + scratch_parent: Path, + validate: Callable[[list[zipfile.ZipInfo]], None], + check_cancelled: Callable[[], None], + progress: Callable[[int, int], None], +) -> Iterator[MetadataArchiveSource]: + """Never extract archive-supplied paths; solid 7z uses private numbered spools. + + The caller owns the open source descriptor and verifies its identity again + before publication. No intermediate CBZ is constructed for non-ZIP input. + """ + header = stream.read(512) + stream.seek(0) + with ExitStack() as stack: + if header.startswith((b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")): + archive = stack.enter_context(zipfile.ZipFile(stream)) + result = MetadataArchiveSource( + archive.infolist(), lambda entry: archive.open(entry), stack.close, archive.comment + ) + elif header.startswith(b"Rar!\x1a\x07"): + result = _open_rar(stream, stack) + elif header.startswith(b"7z\xbc\xaf\x27\x1c"): + result = _open_7z( + stream, stack, limit, scratch_parent, validate, check_cancelled, progress + ) + elif header[257:262] == b"ustar" or path.suffix.casefold() in {".cbt", ".tar"}: + result = _open_tar(stream, stack, limit, check_cancelled) + else: + raise FileSafetyError("Unsupported or unreadable source archive for paired metadata") + validate(result.entries) + check_cancelled() + yield result + + +def _open_tar( + stream: BinaryIO, stack: ExitStack, limit: int, check_cancelled: Callable[[], None] +) -> MetadataArchiveSource: + try: + archive = stack.enter_context(tarfile.open(fileobj=stream, mode="r:*")) # noqa: SIM115 + members = [] + total = 0 + for member in archive: + check_cancelled() + if not (member.isfile() or member.isdir()): + raise FileSafetyError("Archive contains an unsafe or unsupported member") + total += member.size + if member.size < 0 or total > limit: + raise FileSafetyError("Archive decompressed size exceeds limit") + members.append(member) + entries = [_entry(member.name, member.size, member.isdir()) for member in members] + original = dict(zip(entries, members, strict=True)) + + @contextmanager + def read(entry: zipfile.ZipInfo) -> Iterator[IO[bytes]]: + if entry.is_dir(): + yield io.BytesIO() + return + try: + opened = archive.extractfile(original[entry]) + if opened is None: + raise FileSafetyError("Archive member could not be read") + with opened: + yield opened + except tarfile.TarError as exc: + raise FileSafetyError("Source TAR archive member could not be read") from exc + + return MetadataArchiveSource(entries, read, stack.close) + except tarfile.TarError as exc: + raise FileSafetyError("Source TAR archive could not be inspected") from exc + + +def _open_rar(stream: BinaryIO, stack: ExitStack) -> MetadataArchiveSource: + import rarfile # type: ignore[import-untyped] + + from pullbox.core.rar_backend import configure_rarfile_backend + + configure_rarfile_backend() + try: + archive = stack.enter_context(rarfile.RarFile(stream, errors="strict")) + members = archive.infolist() + if archive.needs_password() or any( + member.is_symlink() + or getattr(member, "file_redir", None) is not None + or not (member.is_file() or member.is_dir()) + or ( + member.host_os == rarfile.RAR_OS_UNIX + and stat.S_IFMT(member.mode) not in {0, stat.S_IFREG, stat.S_IFDIR} + ) + or (member.flags & (rarfile.RAR_FILE_SPLIT_BEFORE | rarfile.RAR_FILE_SPLIT_AFTER)) + for member in members + ): + raise FileSafetyError("Archive contains an unsafe or unsupported member") + entries = [ + _entry(str(member.filename), int(member.file_size), bool(member.is_dir())) + for member in members + ] + original = dict(zip(entries, members, strict=True)) + + @contextmanager + def read(entry: zipfile.ZipInfo) -> Iterator[IO[bytes]]: + if entry.is_dir(): + yield io.BytesIO() + return + try: + opened: IO[bytes] = archive.open(original[entry]) + with opened: + yield opened + except rarfile.Error as exc: + raise FileSafetyError("Source RAR archive member could not be read") from exc + + comment = archive.comment + return MetadataArchiveSource( + entries, read, stack.close, comment.encode("utf-8") if comment else b"" + ) + except rarfile.Error as exc: + raise FileSafetyError("Source RAR archive could not be inspected") from exc + + +class _DiskMember(Py7zIO): + def __init__( + self, path: Path, expected_size: int, stack: ExitStack, advanced: Callable[[int], None] + ) -> None: + self.expected_size = expected_size + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600) + self.stream = stack.enter_context(os.fdopen(fd, "w+b")) + self.written = 0 + self.advanced = advanced + + def write(self, data: bytes | bytearray) -> int: + if self.stream.tell() != self.written or self.written + len(data) > self.expected_size: + raise FileSafetyError("Archive expanded beyond its declared size") + count = self.stream.write(data) + self.written += count + self.advanced(count) + return count + + def read(self, size: int | None = None) -> bytes: + return self.stream.read(size if size is not None else -1) + + def seek(self, offset: int, whence: int = 0) -> int: + return self.stream.seek(offset, whence) + + def flush(self) -> None: + self.stream.flush() + + def size(self) -> int: + return self.written + + def close(self) -> None: + self.stream.close() + + +class _DiskFactory(WriterFactory): + def __init__( + self, + directory: Path, + entries: list[zipfile.ZipInfo], + stack: ExitStack, + advanced: Callable[[int], None], + ) -> None: + self.paths = {entry.filename: directory / str(index) for index, entry in enumerate(entries)} + self.entries = {entry.filename: entry for entry in entries} + self.products: dict[str, _DiskMember] = {} + self.stack = stack + self.advanced = advanced + + def create(self, filename: str) -> Py7zIO: + entry = self.entries.get(filename) + if entry is None or entry.is_dir() or filename in self.products: + raise FileSafetyError("Unexpected archive extraction target") + product = _DiskMember(self.paths[filename], entry.file_size, self.stack, self.advanced) + self.products[filename] = product + return product + + +def _open_7z( + stream: BinaryIO, + stack: ExitStack, + limit: int, + scratch_parent: Path, + validate: Callable[[list[zipfile.ZipInfo]], None], + check_cancelled: Callable[[], None], + progress: Callable[[int, int], None], +) -> MetadataArchiveSource: + import py7zr + from py7zr.exceptions import ArchiveError, PasswordRequired + + try: + archive = stack.enter_context(py7zr.SevenZipFile(stream, "r", max_extract_size=limit)) + members = archive.list() + if archive.needs_password() or any( + member.is_symlink or not (member.is_file or member.is_directory) for member in members + ): + raise FileSafetyError("Archive contains an unsafe or unsupported member") + entries = [ + _entry(member.filename, member.uncompressed, member.is_directory) for member in members + ] + validate(entries) + check_cancelled() + directory = Path( + stack.enter_context( + tempfile.TemporaryDirectory(prefix=".pullbox-7z-", dir=scratch_parent) + ) + ) + total = sum(entry.file_size for entry in entries) + current = 0 + lock = threading.Lock() + + def advanced(count: int) -> None: + nonlocal current + with lock: + check_cancelled() + current += count + progress(current, total) + + factory = _DiskFactory(directory, entries, stack, advanced) + progress(0, total) + archive.extract( + targets=[entry.filename for entry in entries if not entry.is_dir()], + recursive=False, + factory=factory, + ) + for entry in entries: + if entry.is_dir(): + continue + product = factory.products.get(entry.filename) + if product is None or product.size() != entry.file_size: + raise FileSafetyError("Archive payload size disagrees with its directory") + product.close() + + def read(entry: zipfile.ZipInfo) -> IO[bytes]: + return io.BytesIO() if entry.is_dir() else factory.paths[entry.filename].open("rb") + + return MetadataArchiveSource(entries, read, stack.close) + except (ArchiveError, PasswordRequired) as exc: + raise FileSafetyError("Source 7z archive could not be inspected") from exc diff --git a/src/pullbox/services/archive_metadata_writing.py b/src/pullbox/services/archive_metadata_writing.py index 8f249973..18e9c51f 100644 --- a/src/pullbox/services/archive_metadata_writing.py +++ b/src/pullbox/services/archive_metadata_writing.py @@ -11,13 +11,14 @@ import structlog -from pullbox.core.archive_metadata import read_open_zip_metadata +from pullbox.core.archive_metadata import read_open_metadata_members from pullbox.core.file_publication import publish_file_without_overwrite from pullbox.core.file_safety import ( DANGEROUS_EXTENSIONS, FileSafetyError, has_archive_member_path_traversal, ) +from pullbox.core.metadata_archive_source import MetadataArchiveSource, open_metadata_archive from pullbox.core.metadata_identity import ExternalIdentityRef from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_rendering import render_archive_metadata @@ -82,10 +83,21 @@ def write_cbz_metadata( ) with os.fdopen(fd, "rb") as source_stream: _check_source(source_path, source_stream, before) - with zipfile.ZipFile(source_stream, "r") as source: - entries = source.infolist() - _validate_members(entries, max_uncompressed_bytes, block_dangerous) - files = read_open_zip_metadata(source) + with open_metadata_archive( + source_stream, + source_path, + limit=max_uncompressed_bytes, + scratch_parent=target_path.parent, + validate=lambda entries: _validate_members( + entries, max_uncompressed_bytes, block_dangerous + ), + check_cancelled=lambda: _check_cancelled(check_cancelled), + progress=lambda current, total: _progress( + progress_callback, check_cancelled, "extracting", current, total + ), + ) as source: + entries = source.entries + files = read_open_metadata_members(entries, source.open) pair = render_archive_metadata( series, issue, @@ -264,7 +276,7 @@ def _progress( def _construct( - source: zipfile.ZipFile, + source: MetadataArchiveSource, stream: BinaryIO, members: list[zipfile.ZipInfo], metadata: dict[str, bytes], diff --git a/src/pullbox/utilities/executors/archive_metadata_staging.py b/src/pullbox/utilities/executors/archive_metadata_staging.py index f67f1d5f..32830c6b 100644 --- a/src/pullbox/utilities/executors/archive_metadata_staging.py +++ b/src/pullbox/utilities/executors/archive_metadata_staging.py @@ -1,4 +1,4 @@ -"""Private paired-metadata staging for journal-owning workflows.""" +"""Private paired CBZ output from ZIP/RAR/7z/TAR for journal-owning workflows.""" import os import shutil diff --git a/tests/unit/test_nonzip_metadata_writing.py b/tests/unit/test_nonzip_metadata_writing.py new file mode 100644 index 00000000..ceae8579 --- /dev/null +++ b/tests/unit/test_nonzip_metadata_writing.py @@ -0,0 +1,295 @@ +"""Real non-ZIP containers exercise paired conversion without an intermediate CBZ.""" + +import io +import stat +import struct +import tarfile +import zipfile +import zlib +from unittest.mock import patch + +import py7zr +import pytest + +from pullbox.core.exceptions import JobCancelledError +from pullbox.core.file_safety import FileSafetyError +from pullbox.services.archive_metadata_rendering import ArchiveMetadataRenderError +from tests.unit.test_archive_metadata_writing import assert_pair, snapshots, write + + +def nonzip_archive(path, members=None, *, mode=stat.S_IFREG | 0o644): + members = members or [("pages/001.jpg", b"page bytes" * 10000)] + if path.suffix == ".cb7": + with py7zr.SevenZipFile(path, "w") as archive: + for name, payload in members: + archive.writestr(payload, name) + elif path.suffix == ".cbt": + with tarfile.open(path, "w") as archive: + for name, payload in members: + info = tarfile.TarInfo(name) + info.size = len(payload) + info.mode = mode & 0o777 + archive.addfile(info, io.BytesIO(payload)) + else: + # Minimal stored RAR4 fixture. The real rarfile parser checks header/data CRCs. + def block(kind, flags, body): + header = struct.pack("50-x"), + ], + ) + original = source.read_bytes() + target = tmp_path / "converted.cbz" + modes = [] + init = zipfile.ZipFile.__init__ + + def tracked(self, file, mode="r", *args, **kwargs): + modes.append(mode) + init(self, file, mode, *args, **kwargs) + + with patch.object(zipfile.ZipFile, "__init__", tracked): + assert write(source, target) + assert [mode for mode in modes if mode in {"w", "x", "a"}] == ["w"] + assert source.read_bytes() == original + assert_pair(target) + with zipfile.ZipFile(target) as archive: + assert archive.read("pages/001.jpg") == b"page bytes" * 10000 + assert set(archive.namelist()) == {"pages/001.jpg", "ComicInfo.xml", "MetronInfo.xml"} + assert set(tmp_path.iterdir()) == {source, target} + + +@pytest.mark.parametrize("suffix", [".cbr", ".cb7", ".cbt"]) +@pytest.mark.parametrize("reason", ["conflict", "duplicate", "dangerous", "oversize"]) +def test_nonzip_failure_preserves_original_and_has_no_output(tmp_path, suffix, reason): + members = [("001.jpg", b"pages")] + if reason == "conflict": + members += [("ComicInfo.xml", b"99")] + elif reason == "duplicate": + members += [("001.JPG", b"other pages")] + elif reason == "dangerous": + members += [("payload.exe", b"unsafe")] + source = nonzip_archive(tmp_path / f"source{suffix}", members) + original = source.read_bytes() + target = tmp_path / "output.cbz" + from pullbox.services.archive_metadata_writing import write_cbz_metadata + + with pytest.raises((FileSafetyError, ArchiveMetadataRenderError)): + write_cbz_metadata( + source, + target, + *snapshots(), + max_uncompressed_bytes=1 if reason == "oversize" else 1000000, + ) + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("suffix", [".cbr", ".cb7", ".cbt"]) +@pytest.mark.parametrize("cancel_stage", ["transferring", "verifying", "publishing"]) +def test_nonzip_cancel_never_publishes(tmp_path, suffix, cancel_stage): + source = nonzip_archive(tmp_path / f"source{suffix}") + original = source.read_bytes() + + def cancel(stage, *_args): + if stage == cancel_stage: + raise JobCancelledError("stop") + + with pytest.raises(JobCancelledError): + write(source, tmp_path / "out.cbz", progress_callback=cancel) + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("name", ["../escape.jpg", "/absolute.jpg", "x/../escape.jpg"]) +def test_tar_traversal_is_rejected_before_reading_pages(tmp_path, name): + source = nonzip_archive(tmp_path / "source.cbt", [(name, b"bad")]) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "out.cbz") + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("kind", [tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.FIFOTYPE]) +def test_tar_special_members_are_never_followed(tmp_path, kind): + source = tmp_path / "source.cbt" + with tarfile.open(source, "w") as archive: + member = tarfile.TarInfo("001.jpg") + member.type = kind + member.linkname = "/etc/passwd" + archive.addfile(member) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "out.cbz") + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("kind", [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR, stat.S_IFBLK]) +def test_rar_special_member_is_not_mistaken_for_a_page(tmp_path, kind): + source = nonzip_archive(tmp_path / "source.cbr", mode=kind | 0o777) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "out.cbz") + + +def test_rar_hardlink_redirection_is_rejected_before_reading(tmp_path, monkeypatch): + import rarfile + + source = nonzip_archive(tmp_path / "source.cbr") + entries = rarfile.RarFile.infolist + + def with_redirection(self): + members = entries(self) + members[0].file_redir = (rarfile.RAR5_XREDIR_HARD_LINK, 0, "other.jpg") + assert members[0].is_file() and not members[0].is_symlink() + return members + + monkeypatch.setattr(rarfile.RarFile, "infolist", with_redirection) + monkeypatch.setattr( + rarfile.RarFile, + "open", + lambda *args, **kwargs: pytest.fail( + "Hard-link redirection must be rejected before reading archive members" + ), + ) + with pytest.raises(FileSafetyError, match="unsafe"): + write(source, tmp_path / "out.cbz") + assert list(tmp_path.iterdir()) == [source] + + +def test_solid_extraction_is_cancellable_and_confined_to_owner_workspace(tmp_path, monkeypatch): + from pullbox.core import metadata_archive_source + + source = nonzip_archive(tmp_path / "source.cb7") + owner_workspace = tmp_path / "private-stage" + owner_workspace.mkdir() + created = [] + create = metadata_archive_source.tempfile.TemporaryDirectory + + def track(*args, **kwargs): + directory = create(*args, **kwargs) + created.append(directory.name) + return directory + + def cancel(stage, current, *_args): + if stage == "extracting" and current: + raise JobCancelledError("stop solid extraction") + + monkeypatch.setattr(metadata_archive_source.tempfile, "TemporaryDirectory", track) + original = source.read_bytes() + with pytest.raises(JobCancelledError): + write(source, owner_workspace / "output.cbz", progress_callback=cancel) + from pathlib import Path + + assert created and all(Path(path).parent == owner_workspace for path in created) + assert all(not Path(path).exists() for path in created) + assert source.read_bytes() == original + assert not list(owner_workspace.iterdir()) + + +@pytest.mark.parametrize("suffix", [".cbr", ".cb7", ".cbt"]) +def test_nonzip_changed_source_or_racing_destination_is_not_overwritten(tmp_path, suffix): + source = nonzip_archive(tmp_path / f"source{suffix}") + target = tmp_path / "out.cbz" + + def replace(stage, *_args): + if stage == "publishing": + target.write_bytes(b"someone else's file") + + original = source.read_bytes() + with pytest.raises(FileExistsError): + write(source, target, progress_callback=replace) + assert source.read_bytes() == original + assert target.read_bytes() == b"someone else's file" + + target.unlink() + + def change_source(stage, *_args): + if stage == "publishing": + source.write_bytes(b"external replacement") + + with pytest.raises(FileSafetyError, match="changed"): + write(source, target, progress_callback=change_source) + assert source.read_bytes() == b"external replacement" + assert not target.exists() + + +@pytest.mark.parametrize("suffix", [".cb7", ".cbt"]) +async def test_real_worker_stages_nonzip_pair_privately(tmp_path, suffix): + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + source = nonzip_archive(tmp_path / f"source{suffix}") + original = source.read_bytes() + async with stage_cbz_metadata_interruptible( + source, tmp_path, *snapshots(), max_uncompressed_bytes=1000000 + ) as staged: + assert_pair(staged.path) + staged.check_unchanged() + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("suffix", [".cbr", ".cbt"]) +def test_corrupt_nonzip_payload_never_publishes(tmp_path, suffix): + source = nonzip_archive(tmp_path / f"source{suffix}", [("001.jpg", b"known page payload")]) + data = source.read_bytes() + if suffix == ".cbr": + data = data.replace(b"known page payload", b"wrong page payload") + else: + data = data[:513] + source.write_bytes(data) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "out.cbz") + assert source.read_bytes() == data + assert list(tmp_path.iterdir()) == [source] + + +def test_tar_budget_is_checked_before_advancing_past_oversized_member(tmp_path, monkeypatch): + from pullbox.services.archive_metadata_writing import write_cbz_metadata + + source = nonzip_archive(tmp_path / "source.cbt") + advance = tarfile.TarFile.next + + def bounded_next(self): + assert not self.members or self.firstmember is not None, ( + "Inspect the declared size before advancing past the first header" + ) + return advance(self) + + monkeypatch.setattr(tarfile.TarFile, "next", bounded_next) + with pytest.raises(FileSafetyError, match="size"): + write_cbz_metadata(source, tmp_path / "out.cbz", *snapshots(), max_uncompressed_bytes=10) From a93bf1ad99798724c19804d863b1093a73b47a71 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 09:11:12 -0700 Subject: [PATCH 73/97] fix(library): make conversion publication recoverable --- ...w1x2y345_add_library_conversion_journal.py | 47 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 10 + docs/development/DATABASE_STANDARDS.md | 21 + src/pullbox/app.py | 6 + src/pullbox/models/__init__.py | 2 + src/pullbox/models/library_conversion.py | 27 ++ .../services/archive_metadata_publication.py | 4 + .../services/library_conversion_files.py | 211 +++++++++ .../services/library_conversion_recovery.py | 321 +++++++++++++ .../services/library_convert_service.py | 109 ++--- .../services/library_mutation_coordination.py | 3 + .../test_library_conversion_lifecycle.py | 440 ++++++++++++++++++ .../test_library_conversion_migration.py | 56 +++ tests/integration/test_migrations.py | 3 +- .../test_reader_file_continuity.py | 11 +- tests/unit/test_app_lifespan.py | 17 +- tests/unit/test_library_convert_service.py | 50 +- 17 files changed, 1245 insertions(+), 93 deletions(-) create mode 100644 alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py create mode 100644 src/pullbox/models/library_conversion.py create mode 100644 src/pullbox/services/library_conversion_files.py create mode 100644 src/pullbox/services/library_conversion_recovery.py create mode 100644 tests/integration/metadata_identity/test_library_conversion_lifecycle.py create mode 100644 tests/integration/metadata_identity/test_library_conversion_migration.py diff --git a/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py b/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py new file mode 100644 index 00000000..4776f302 --- /dev/null +++ b/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py @@ -0,0 +1,47 @@ +"""Retain original, output and backup evidence for Library conversion recovery.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "b9v0w1x2y345" +down_revision = "a8u9v0w1x234" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "library_conversions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("operation_id", sa.String(36), nullable=False, unique=True), + sa.Column( + "library_file_id", sa.Integer(), sa.ForeignKey("library_files.id", ondelete="SET NULL") + ), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("state", sa.String(10), nullable=False), + sa.Column("plan_json", sa.Text(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.CheckConstraint( + "state IN ('intended','registered','complete','abandoned','review')", + name="ck_library_conversion_state", + ), + sa.CheckConstraint( + "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_conversion_plan_size" + ), + ) + op.create_index("ix_library_conversions_active", "library_conversions", ["active"]) + + +def downgrade() -> None: + table = sa.table("library_conversions", sa.column("state")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned") + ): + raise RuntimeError("Resolve retained library conversion evidence before downgrading.") + op.drop_table("library_conversions") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index c160b417..f4ffa8de 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -612,6 +612,16 @@ literal filename characters. Conversion, deletion, bulk utility writers and other mutation routes have not yet joined this contract. This does not authorize paired-writer activation or claim crash recovery for arbitrary legacy renames. +Library browser conversion now prepares output and an independent original backup +in private, interruptible workers, records durable intent, publishes exclusively, +and updates the existing registration before deleting the original. Recovery runs +before startup file writers and never guesses ownership from a filename or removes +unproven output after an ambiguous commit. Same-root and cross-filesystem trash use +the same staged protocol. This retains the existing conversion content behavior; +the paired renderer is not activated by this lifecycle change. Bulk conversion, +deletion and other writers still need to join coordination before paired background +metadata writing can be enabled. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 6cc1c54b..5b98c739 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -580,6 +580,27 @@ is not undone when the request is cancelled. Conversion, deletion, bulk utility writers, import placement and other mutation paths still need the shared contract before paired background writing can be enabled. +### Library Conversion Journal + +`library_conversions` retains bounded original, staged output and independent trash +backup evidence. Preparation uses interruptible child processes outside database +write transactions. The Library browser owns the session lifecycle: it commits +intent before exclusive publication, then commits the existing LibraryFile's new +path before removing the unchanged original. Reader state and issue identity keep +their existing registration. Copying the backup into private staging on the trash +filesystem supports cross-filesystem trash without a long SQLite write lock. + +Conversion reservations share admission with archive metadata publication and +short renames. They cover original, output, backup and both stage paths, survive +LibraryFile deletion, and do not expire. Startup recovery examines bounded batches, +hashes outside write transactions, and rechecks the files under admission before +registration or cleanup. It never republishes private stages. Proven untouched +intent is abandoned; proven output/backup can finish registration and cleanup; +changed or unproven evidence remains reserved for review. A lost commit response +never permits deleting a registered output. Downgrade refuses retained completed +or unresolved conversion evidence. Bulk utilities and other mutation entry points +still require coordinated lifecycles before paired background writers activate. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/app.py b/src/pullbox/app.py index 3f086aca..6a835deb 100644 --- a/src/pullbox/app.py +++ b/src/pullbox/app.py @@ -297,6 +297,12 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.warning("library_path_reconciliation_failed", exc_info=True) + # Retained conversion intent must be reconciled before background file writers. + from pullbox.services.library_conversion_recovery import recover_library_conversions + + async with get_session_factory()() as session: + await recover_library_conversions(session) + # One-time migration: move covers from series folders to .covers/ from pullbox.core.cover_migration import migrate_covers_to_dotcovers diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index dd28ec55..da3818f6 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -60,6 +60,7 @@ LibraryRootPolicySource, MatchConfidence, ) +from pullbox.models.library_conversion import LibraryConversion from pullbox.models.matching_suggestion import MatchingSuggestion, SuggestionStatus from pullbox.models.metadata_baseline import ( IssueMetadataBaseline, @@ -197,6 +198,7 @@ "ItemState", "JobState", "JobType", + "LibraryConversion", "LibraryFile", "LibraryRoot", "LibraryRootPolicy", diff --git a/src/pullbox/models/library_conversion.py b/src/pullbox/models/library_conversion.py new file mode 100644 index 00000000..39019f84 --- /dev/null +++ b/src/pullbox/models/library_conversion.py @@ -0,0 +1,27 @@ +"""Retained conversion evidence independent of the registered file's lifetime.""" + +from sqlalchemy import Boolean, CheckConstraint, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin + + +class LibraryConversion(Base, IdentityMixin, TimestampMixin): + __tablename__ = "library_conversions" + __table_args__ = ( + CheckConstraint( + "state IN ('intended','registered','complete','abandoned','review')", + name="ck_library_conversion_state", + ), + CheckConstraint( + "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_conversion_plan_size" + ), + ) + + operation_id: Mapped[str] = mapped_column(String(36), unique=True) + library_file_id: Mapped[int | None] = mapped_column( + ForeignKey("library_files.id", ondelete="SET NULL") + ) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + state: Mapped[str] = mapped_column(String(10), default="intended") + plan_json: Mapped[str] = mapped_column(Text) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index e03974be..521be690 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -269,6 +269,7 @@ async def record_archive_publication( elapsed time is never permission to let another writer take over. """ from pullbox.services.import_archive_publication import require_import_archive_owner + from pullbox.services.library_conversion_recovery import require_no_library_conversion _clean_session(session) encoded = _encode(plan) @@ -276,6 +277,9 @@ async def record_archive_publication( try: async with metadata_write_scope(session): await lock_file_mutation_admission(session) + await require_no_library_conversion( + session, plan.target.path, plan.stage_path, include_descendants=False + ) await _lock_binding(session, plan) existing = await load_archive_publication(session, operation_id) if existing: diff --git a/src/pullbox/services/library_conversion_files.py b/src/pullbox/services/library_conversion_files.py new file mode 100644 index 00000000..bc072426 --- /dev/null +++ b/src/pullbox/services/library_conversion_files.py @@ -0,0 +1,211 @@ +"""Private conversion preparation and immutable evidence for short publication.""" + +import asyncio +import os +import stat +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path +from threading import Event +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from pullbox.core.exceptions import ValidationError +from pullbox.core.file_publication import publish_file_without_overwrite +from pullbox.services.archive_metadata_binding import FileFingerprint +from pullbox.services.archive_metadata_publication import _digest, _file_work, _fingerprint +from pullbox.utilities.executors.archive_subprocess import ( + convert_file_interruptible, + transfer_file_interruptible, +) + + +class ConversionFile(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + path: Path + fingerprint: FileFingerprint + digest: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class ConversionBinding(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + root_id: int + root_path: str + file_id: int | None + issue_id: int | None + file_format: str | None + file_size: int | None + file_modified_at: datetime | None + + +class ConversionPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[1] = 1 + binding: ConversionBinding + original: ConversionFile + output: ConversionFile + backup: ConversionFile + output_stage: Path + backup_stage: Path + directories: tuple[tuple[Path, int, int, int], ...] + + @property + def paths(self) -> tuple[Path, ...]: + return ( + self.original.path, + self.output.path, + self.backup.path, + self.output_stage, + self.backup_stage, + ) + + +def decode_plan(encoded: str) -> ConversionPlan: + if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536: + raise ValidationError("Conversion recovery evidence exceeds its limit.") + plan = ConversionPlan.model_validate_json(encoded) + if ( + any(not path.is_absolute() or ".." in path.parts for path in plan.paths) + or len(set(plan.paths)) != 5 + or plan.output.path != plan.original.path.with_suffix(".cbz") + or not plan.original.path.is_relative_to(Path(plan.binding.root_path).resolve()) + or plan.output_stage.parent.parent != plan.output.path.parent + or plan.backup_stage.parent.parent != plan.backup.path.parent + or any( + not stage.parent.name.startswith(".pullbox-conversion-") + for stage in (plan.output_stage, plan.backup_stage) + ) + ): + raise ValidationError("Conversion recovery evidence is invalid.") + return plan + + +def _regular(path: Path) -> FileFingerprint: + evidence = _fingerprint(path) + if evidence is None or not stat.S_ISREG(evidence[5]) or path.resolve() != path: + raise ValidationError("Conversion requires an unchanged regular file.") + return evidence + + +def directories(*paths: Path) -> tuple[tuple[Path, int, int, int], ...]: + result = [] + for path in sorted(set(parent for value in paths for parent in value.parents)): + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or path.resolve() != path: + raise ValidationError("Conversion locations must not contain symbolic links.") + result.append((path, info.st_dev, info.st_ino, info.st_mode)) + return tuple(result) + + +def check_directories(plan: ConversionPlan) -> None: + for path, device, inode, mode in plan.directories: + info = path.lstat() + if (info.st_dev, info.st_ino, info.st_mode) != (device, inode, mode): + raise ValidationError("Conversion locations changed; review before retrying.") + + +def matches(actual: FileFingerprint | None, expected: FileFingerprint) -> bool: + # Exclusive rename/link publication may change ctime, but not content identity. + return bool(actual and actual[:4] == expected[:4] and actual[5] == expected[5]) + + +def sync_directory(path: Path) -> None: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def publish(plan: ConversionPlan) -> None: + check_directories(plan) + if _fingerprint(plan.original.path) != plan.original.fingerprint: + raise ValidationError("The original changed during conversion.") + for stage, destination in ((plan.backup_stage, plan.backup), (plan.output_stage, plan.output)): + if stage.resolve(strict=True) != stage or _fingerprint(stage) != destination.fingerprint: + raise ValidationError("Prepared conversion files changed.") + publish_file_without_overwrite(stage, destination.path) + sync_directory(destination.path.parent) + + +@asynccontextmanager +async def prepare_conversion( + source: Path, backup: Path, binding: ConversionBinding +) -> AsyncIterator[ConversionPlan]: + """No public destination or source mutation; workers are reaped before cleanup.""" + source = source.absolute() + before = _regular(source) + backup.parent.mkdir(parents=True, exist_ok=True) + dirs = directories(source, backup) + with ( + tempfile.TemporaryDirectory(prefix=".pullbox-conversion-", dir=source.parent) as output_dir, + tempfile.TemporaryDirectory(prefix=".pullbox-conversion-", dir=backup.parent) as backup_dir, + ): + output_stage = Path(output_dir) / "output.cbz" + backup_stage = Path(backup_dir) / "original" + await convert_file_interruptible(source, "cbz", output_path=output_stage) + await transfer_file_interruptible(source, backup_stage, "copy") + + def inspect(stop: Event) -> ConversionPlan: + if _regular(source) != before: + raise ValidationError("The original changed during conversion.") + # Permission metadata is applied only to new owned stages, never to the source. + os.chmod(output_stage, stat.S_IMODE(before[5]) & 0o777) + now = datetime.now(UTC).timestamp() + os.utime(backup_stage, (now, now)) + files = [] + for path in (source, output_stage, backup_stage): + info = _regular(path) + digest = _digest(path, info, stop) + if path != source: + with path.open("rb") as stream: + os.fsync(stream.fileno()) + files.append(ConversionFile(path=path, fingerprint=info, digest=digest)) + if files[0].digest != files[2].digest or files[0].fingerprint != before: + raise ValidationError("The original changed while its backup was prepared.") + plan = ConversionPlan( + binding=binding, + original=files[0], + output=files[1].model_copy(update={"path": source.with_suffix(".cbz")}), + backup=files[2].model_copy(update={"path": backup}), + output_stage=output_stage, + backup_stage=backup_stage, + directories=dirs, + ) + check_directories(plan) + return decode_plan(plan.model_dump_json()) + + yield await _file_work(inspect) + + +async def inspect_conversion(plan: ConversionPlan) -> dict[str, ConversionFile | None]: + def inspect(stop: Event) -> dict[str, ConversionFile | None]: + check_directories(plan) + result: dict[str, ConversionFile | None] = {} + for name in ("original", "output", "backup"): + expected: ConversionFile = getattr(plan, name) + actual = _fingerprint(expected.path) + result[name] = None + if actual is not None: + digest = "0" * 64 + if matches(actual, expected.fingerprint): + digest = _digest(expected.path, actual, stop) + result[name] = ConversionFile(path=expected.path, fingerprint=actual, digest=digest) + return result + + return await _file_work(inspect) + + +async def remove_original(plan: ConversionPlan, inspected: ConversionFile | None) -> None: + def remove() -> None: + check_directories(plan) + if _fingerprint(plan.original.path) != (inspected.fingerprint if inspected else None): + raise ValidationError("The original changed before conversion cleanup.") + if inspected: + plan.original.path.unlink() + sync_directory(plan.original.path.parent) + + await asyncio.to_thread(remove) diff --git a/src/pullbox/services/library_conversion_recovery.py b/src/pullbox/services/library_conversion_recovery.py new file mode 100644 index 00000000..2b267863 --- /dev/null +++ b/src/pullbox/services/library_conversion_recovery.py @@ -0,0 +1,321 @@ +"""Durable Library conversion admission, registration and evidence-based recovery.""" + +import asyncio +from pathlib import Path +from uuid import UUID + +import structlog +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.core.library_file_ownership import require_mutable_library_target +from pullbox.models import LibraryFile, LibraryRoot +from pullbox.models.library import LibraryFileStorageMode +from pullbox.models.library_conversion import LibraryConversion +from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.library_conversion_files import ( + ConversionBinding, + ConversionFile, + ConversionPlan, + check_directories, + decode_plan, + inspect_conversion, + matches, + publish, + remove_original, +) +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, +) + +logger = structlog.get_logger(__name__) + + +async def read_conversion_binding(session: AsyncSession, source: Path) -> ConversionBinding: + await require_mutable_library_target( + session, source, include_descendants=False, operation="converted" + ) + file = await session.scalar( + select(LibraryFile) + .where(LibraryFile.file_path == str(source)) + .execution_options(populate_existing=True) + ) + roots = list( + ( + await session.scalars( + select(LibraryRoot) + .where(LibraryRoot.enabled.is_(True), LibraryRoot.allow_managed_writes.is_(True)) + .limit(201) + .execution_options(populate_existing=True) + ) + ).all() + ) + candidates = [root for root in roots if source.is_relative_to(Path(root.path).resolve())] + if file is not None: + candidates = [root for root in candidates if root.id == file.library_root_id] + elif candidates: + depth = max(len(Path(root.path).resolve().parts) for root in candidates) + candidates = [root for root in candidates if len(Path(root.path).resolve().parts) == depth] + if ( + len(roots) > 200 + or len(candidates) != 1 + or ( + file is not None + and ( + file.library_root_id != candidates[0].id + or file.storage_mode is not LibraryFileStorageMode.MANAGED + ) + ) + ): + raise ValidationError( + "Conversion requires one enabled library root that allows managed files." + ) + root = candidates[0] + return ConversionBinding( + root_id=root.id, + root_path=root.path, + file_id=file.id if file else None, + issue_id=file.issue_id if file else None, + file_format=file.file_format.value if file else None, + file_size=file.file_size if file else None, + file_modified_at=file.file_modified_at if file else None, + ) + + +async def require_no_library_conversion( + session: AsyncSession, + *paths: Path, + include_descendants: bool, + excluding: str | None = None, +) -> None: + targets = {variant for path in paths for variant in (path.absolute(), path.resolve())} + last_id = 0 + while True: + query = select( + LibraryConversion.id, + case( + ( + func.length(LibraryConversion.plan_json) <= 65536, + LibraryConversion.plan_json, + ), + else_=None, + ), + ).where(LibraryConversion.active.is_(True), LibraryConversion.id > last_id) + if excluding is not None: + query = query.where(LibraryConversion.operation_id != excluding) + rows = (await session.execute(query.order_by(LibraryConversion.id).limit(8))).all() + if not rows: + return + for row_id, encoded in rows: + last_id = row_id + try: + plan = decode_plan(encoded) + except (TypeError, ValueError): + raise ValidationError( + "A pending conversion needs recovery before files can change." + ) from None + if any( + reserved == target or (include_descendants and reserved.is_relative_to(target)) + for reserved in plan.paths + for target in targets + ): + raise ValidationError( + "A conversion is pending for this file or folder. " + "Restart Pullbox to recover it before retrying." + ) + + +async def record_conversion( + session: AsyncSession, plan: ConversionPlan, operation_id: UUID +) -> None: + from pullbox.services.library_mutation_coordination import require_no_archive_publication + + decode_plan(plan.model_dump_json()) + await lock_file_mutation_admission(session) + await require_no_archive_publication(session, *plan.paths, include_descendants=False) + if await read_conversion_binding(session, plan.original.path) != plan.binding: + raise ValidationError("The library registration changed during conversion.") + if await session.scalar( + select(LibraryFile.id).where(LibraryFile.file_path == str(plan.output.path)) + ): + raise ValidationError("The converted file's destination is already registered.") + check_directories(plan) + if _fingerprint(plan.original.path) != plan.original.fingerprint: + raise ValidationError("The original changed during conversion.") + session.add( + LibraryConversion( + operation_id=str(operation_id), + library_file_id=plan.binding.file_id, + plan_json=plan.model_dump_json(), + ) + ) + await session.flush() + session.info[f"conversion_intent:{operation_id}"] = session.sync_session.get_transaction() + + +async def publish_conversion(session: AsyncSession, operation_id: UUID) -> None: + if ( + session.in_transaction() + and session.info.get(f"conversion_intent:{operation_id}") + is session.sync_session.get_transaction() + ): + raise ValidationError("Conversion intent must commit before publication.") + await lock_file_mutation_admission(session) + row = await session.scalar( + select(LibraryConversion) + .where(LibraryConversion.operation_id == str(operation_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None or not row.active or row.state != "intended": + raise ValidationError("Conversion has already been recovered; retry from the Library.") + plan = decode_plan(row.plan_json) + if await read_conversion_binding(session, plan.original.path) != plan.binding: + raise ValidationError("The library registration changed during conversion.") + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(publish, plan))) + + +async def recover_conversion(session: AsyncSession, operation_id: UUID) -> str: + """Own clean session transactions; never resume a converter or publish a stage.""" + + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Conversion recovery requires a clean session.") + row = await session.scalar( + select(LibraryConversion) + .where(LibraryConversion.operation_id == str(operation_id)) + .execution_options(populate_existing=True) + ) + if row is None: + return "missing" + encoded, state, active = row.plan_json, row.state, row.active + await session.commit() + if not active or state == "review": + return state + plan = decode_plan(encoded) + inspected = await inspect_conversion(plan) + return await _apply_inspected_conversion(session, operation_id, encoded, plan, inspected) + + +async def _apply_inspected_conversion( + session: AsyncSession, + operation_id: UUID, + encoded: str, + plan: ConversionPlan, + inspected: dict[str, ConversionFile | None], +) -> str: + from pullbox.services.library_convert_service import _sync_converted_file_record + + await lock_file_mutation_admission(session) + row = await session.scalar( + select(LibraryConversion) + .where(LibraryConversion.operation_id == str(operation_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None or not row.active or row.state == "review": + await session.rollback() + return row.state if row else "missing" + if row.plan_json != encoded: + raise ValidationError("Conversion evidence changed during recovery.") + check_directories(plan) + for name, actual in inspected.items(): + if _fingerprint(getattr(plan, name).path) != (actual.fingerprint if actual else None): + raise ValidationError("Conversion files changed during recovery.") + proven = { + name: bool( + actual + and matches(actual.fingerprint, getattr(plan, name).fingerprint) + and actual.digest == getattr(plan, name).digest + ) + for name, actual in inspected.items() + } + if inspected["output"] is None and proven["original"] and row.state == "intended": + row.state, row.active = "abandoned", False + await session.commit() + return "abandoned" + if ( + not proven["output"] + or not proven["backup"] + or (inspected["original"] is not None and not proven["original"]) + ): + row.state = "review" + await session.commit() + return "review" + + if row.state == "intended": + try: + current = await read_conversion_binding(session, plan.original.path) + if current != plan.binding or not proven["original"]: + raise ValidationError("Conversion registration changed.") + if await session.scalar( + select(LibraryFile.id).where(LibraryFile.file_path == str(plan.output.path)) + ): + raise ValidationError("Conversion destination is already registered.") + await _sync_converted_file_record( + session, before_path=str(plan.original.path), after_path=str(plan.output.path) + ) + except ValidationError: + row.state = "review" + await session.commit() + return "review" + row.state = "registered" + # This commit atomically records registration. Lost acknowledgements leave + # the original and the journal intact; subsequent recovery reads DB truth. + await session.commit() + return await _apply_inspected_conversion(session, operation_id, encoded, plan, inspected) + + current = await read_conversion_binding(session, plan.output.path) + expected = plan.binding + if ( + current.file_id != expected.file_id + or current.issue_id != expected.issue_id + or current.root_id != expected.root_id + or current.root_path != expected.root_path + or ( + current.file_id is not None + and (current.file_format != "cbz" or current.file_size != plan.output.fingerprint[2]) + ) + ): + row.state = "review" + await session.commit() + return "review" + await finish_short_mutation(asyncio.create_task(remove_original(plan, inspected["original"]))) + row.state, row.active = "complete", False + await session.commit() + return "complete" + + +async def recover_library_conversions(session: AsyncSession) -> int: + """Bounded startup pass; unresolved evidence stays reserved and is logged.""" + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Conversion recovery requires a clean session.") + last_id = completed = 0 + while True: + rows = ( + await session.execute( + select(LibraryConversion.id, LibraryConversion.operation_id) + .where( + LibraryConversion.active.is_(True), + LibraryConversion.state != "review", + LibraryConversion.id > last_id, + ) + .order_by(LibraryConversion.id) + .limit(8) + ) + ).all() + await session.commit() + if not rows: + return completed + for row_id, operation in rows: + last_id = row_id + try: + state = await recover_conversion(session, UUID(operation)) + completed += state in {"complete", "abandoned"} + logger.info("library_conversion_recovered", operation_id=operation, state=state) + except Exception: + await session.rollback() + logger.warning( + "library_conversion_recovery_deferred", operation_id=operation, exc_info=True + ) diff --git a/src/pullbox/services/library_convert_service.py b/src/pullbox/services/library_convert_service.py index 77c569fa..893ecf80 100644 --- a/src/pullbox/services/library_convert_service.py +++ b/src/pullbox/services/library_convert_service.py @@ -2,19 +2,31 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING +from uuid import uuid4 import structlog from sqlalchemy import select from pullbox.core.exceptions import ValidationError -from pullbox.core.library_file_ownership import require_mutable_library_target from pullbox.models.library import FileFormat, LibraryFile -from pullbox.utilities.executors.file_converter import convert_file -from pullbox.utilities.settings import move_file_to_utility_trash, restore_file_from_utility_trash +from pullbox.services.library_conversion_files import prepare_conversion +from pullbox.services.library_conversion_recovery import ( + publish_conversion, + read_conversion_binding, + record_conversion, + recover_conversion, +) +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, + require_no_archive_publication, +) +from pullbox.utilities.settings import build_trash_destination if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -48,6 +60,7 @@ async def _sync_converted_file_record( library_file.file_path = after_path library_file.file_name = updated_path.name library_file.file_format = FileFormat.CBZ + library_file.file_hash = None if metadata_embedded: library_file.has_comicinfo = True if updated_path.exists(): @@ -73,62 +86,42 @@ async def convert_library_file( trash_dir: Path, trash_relative_path: str | Path, ) -> LibraryConvertOutcome: - """Convert a single Library browser file immediately and sync tracked DB metadata.""" - converted_path: Path | None = None - trash_path: Path | None = None - + """Own the conversion session lifecycle, retaining recoverable public artifacts.""" + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Conversion requires a clean session.") + source = source.absolute() + operation_id = uuid4() + target = source.with_suffix(".cbz") + relative = Path(trash_relative_path) + if relative.is_absolute() or ".." in relative.parts: + raise ValidationError("Invalid conversion trash location.") + backup = build_trash_destination(trash_dir.absolute(), source, relative_path=relative) + backup = backup.parent / f"conversion-{operation_id.hex}" / backup.name try: - await require_mutable_library_target( - session, - source, - include_descendants=False, - operation="converted", - ) - converted_path = await convert_file(source, "cbz") - trash_path = move_file_to_utility_trash( - source, - trash_dir, - relative_path=trash_relative_path, - ) - await _sync_converted_file_record( - session, - before_path=str(source), - after_path=str(converted_path), - metadata_embedded=False, + await lock_file_mutation_admission(session) + await require_no_archive_publication( + session, source, target, backup, include_descendants=False ) + binding = await read_conversion_binding(session, source) + if target.exists() or target.is_symlink() or target == source: + raise FileExistsError await session.commit() - return LibraryConvertOutcome( - kind="file", - source_path=str(source), - target_path=str(converted_path), - original_trash_path=str(trash_path), - ) - except ValidationError: - await session.rollback() - raise - except Exception as exc: - await session.rollback() - - restored = False - if trash_path is not None and trash_path.exists() and not source.exists(): - try: - restore_file_from_utility_trash( - trash_path, - source, - converted_path=converted_path, - ) - restored = True - except Exception: - logger.exception( - "library_convert_rollback_failed", - source_path=str(source), - target_path=str(converted_path) if converted_path is not None else None, - trash_path=str(trash_path), + async with prepare_conversion(source, backup, binding) as plan: + await record_conversion(session, plan, operation_id) + await finish_short_mutation(asyncio.create_task(session.commit())) + await publish_conversion(session, operation_id) + await finish_short_mutation(asyncio.create_task(session.commit())) + state = await recover_conversion(session, operation_id) + if state != "complete": + raise ValidationError( + "Conversion needs review. The original and recovery evidence were preserved." ) - elif converted_path is not None and converted_path.exists(): - converted_path.unlink() - - message = _conversion_error_message(exc) - if restored and message == "Conversion could not be completed.": - message += " The original file was restored." - raise ValidationError(message) from exc + return LibraryConvertOutcome("file", str(source), str(target), str(backup)) + except BaseException as exc: + await session.rollback() + if isinstance(exc, ValidationError) or not isinstance(exc, Exception): + raise + logger.warning( + "library_conversion_interrupted", operation_id=str(operation_id), exc_info=True + ) + raise ValidationError(_conversion_error_message(exc)) from exc diff --git a/src/pullbox/services/library_mutation_coordination.py b/src/pullbox/services/library_mutation_coordination.py index 3485cf6a..ca4bc44e 100644 --- a/src/pullbox/services/library_mutation_coordination.py +++ b/src/pullbox/services/library_mutation_coordination.py @@ -58,6 +58,9 @@ async def require_no_archive_publication( publication evidence behind. Bound each page and fail closed on corrupt plans. """ from pullbox.services.archive_metadata_publication import ArchivePublicationPlan + from pullbox.services.library_conversion_recovery import require_no_library_conversion + + await require_no_library_conversion(session, *paths, include_descendants=include_descendants) targets = {variant for path in paths for variant in (path.absolute(), path.resolve())} last_id = 0 diff --git a/tests/integration/metadata_identity/test_library_conversion_lifecycle.py b/tests/integration/metadata_identity/test_library_conversion_lifecycle.py new file mode 100644 index 00000000..35699d64 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_conversion_lifecycle.py @@ -0,0 +1,440 @@ +"""Real archive and database evidence for Library conversion ownership.""" + +import asyncio +import threading +import zipfile +from contextlib import asynccontextmanager, suppress +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from pullbox.core.exceptions import ValidationError +from pullbox.models import LibraryFile, LibraryRoot +from pullbox.models.library import FileFormat +from pullbox.models.library_conversion import LibraryConversion +from pullbox.services import library_conversion_files as files +from pullbox.services import library_conversion_recovery as recovery +from pullbox.services import library_convert_service as service + + +async def seed(factory, directory, *, managed=True): + source = directory / "Issue.cbr" + with zipfile.ZipFile(source, "w") as archive: + archive.writestr("001.jpg", b"page bytes") + async with factory() as session: + root = LibraryRoot( + name="Comics", path=str(directory), enabled=True, allow_managed_writes=managed + ) + session.add(root) + await session.flush() + file = LibraryFile( + file_path=str(source), + file_name=source.name, + file_format=FileFormat.CBR, + file_size=source.stat().st_size, + file_modified_at=datetime.fromtimestamp(source.stat().st_mtime, UTC), + library_root_id=root.id, + ) + session.add(file) + await session.commit() + return source, file.id + + +async def convert(session, source, tmp_path): + return await service.convert_library_file( + session, source=source, trash_dir=tmp_path / "trash", trash_relative_path=source.name + ) + + +async def test_original_remains_until_registration_commits( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, file_id = await seed(factory, tmp_path) + original = source.read_bytes() + sync = service._sync_converted_file_record + + async def guarded_sync(*args, **kwargs): + assert source.read_bytes() == original, "Original removed before registration committed" + await sync(*args, **kwargs) + + monkeypatch.setattr(service, "_sync_converted_file_record", guarded_sync) + async with factory() as session: + result = await convert(session, source, tmp_path) + assert not source.exists() + assert Path(result.original_trash_path).read_bytes() == original + with zipfile.ZipFile(result.target_path) as archive: + assert archive.read("001.jpg") == b"page bytes" + async with factory() as session: + row = await session.get(LibraryFile, file_id) + assert row.file_path == result.target_path + assert row.file_format is FileFormat.CBZ + + +async def test_conversion_rechecks_root_write_policy(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path, managed=False) + before = source.read_bytes() + async with factory() as session: + with pytest.raises(ValidationError, match="managed"): + await convert(session, source, tmp_path) + assert source.read_bytes() == before + assert not source.with_suffix(".cbz").exists() + + +async def test_lost_registration_commit_ack_preserves_registered_output( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, file_id = await seed(factory, tmp_path) + target = source.with_suffix(".cbz") + async with factory() as session: + commit = session.commit + lost = False + + async def lose_ack(): + nonlocal lost + converted = any( + isinstance(row, LibraryFile) and row.file_path == str(target) + for row in session.dirty + ) + await commit() + if converted and not lost: + lost = True + raise OSError("lost commit acknowledgement") + + monkeypatch.setattr(session, "commit", lose_ack) + with suppress(ValidationError): + await convert(session, source, tmp_path) + assert lost + async with factory() as session: + path = await session.scalar(select(LibraryFile.file_path).where(LibraryFile.id == file_id)) + assert path == str(target) + assert target.is_file(), "Compensation deleted the successfully registered output" + async with factory() as session: + assert await recovery.recover_library_conversions(session) == 1 + assert not source.exists() + + +@asynccontextmanager +async def prepared(factory, tmp_path): + source, file_id = await seed(factory, tmp_path) + async with factory() as session: + binding = await recovery.read_conversion_binding(session, source) + async with files.prepare_conversion(source, tmp_path / "trash" / source.name, binding) as plan: + yield source, file_id, plan, uuid4() + + +@pytest.mark.parametrize("point", ["intent", "backup", "output"]) +async def test_restart_classifies_each_publication_boundary(identity_probe_db, tmp_path, point): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (source, file_id, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + await session.commit() + if point in {"backup", "output"}: + files.publish_file_without_overwrite(plan.backup_stage, plan.backup.path) + if point == "output": + files.publish_file_without_overwrite(plan.output_stage, plan.output.path) + # All private stages are gone, just as after interrupted preparation cleanup. + async with factory() as session: + state = await recovery.recover_conversion(session, operation) + assert state == ("complete" if point == "output" else "abandoned") + row = await session.get(LibraryFile, file_id) + assert row.file_path == str(plan.output.path if point == "output" else source) + assert source.exists() is (point != "output") + if point == "output": + assert plan.backup.path.is_file() + async with factory() as session: + assert await recovery.recover_conversion(session, operation) == state + + +async def test_intent_must_commit_before_publication(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (source, _, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + with pytest.raises(ValidationError, match="commit"): + await recovery.publish_conversion(session, operation) + await session.rollback() + assert source.exists() + assert not plan.output.path.exists() + assert not plan.backup.path.exists() + + +@pytest.mark.parametrize("changed", ["original", "output", "backup", "registration"]) +async def test_recovery_preserves_changed_evidence(identity_probe_db, tmp_path, changed): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (source, file_id, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + await session.commit() + await recovery.publish_conversion(session, operation) + await session.commit() + if changed == "registration": + async with factory() as session: + await session.delete(await session.get(LibraryFile, file_id)) + await session.commit() + else: + getattr(plan, changed).path.write_bytes(b"external replacement") + before = {path: path.read_bytes() for path in (source, plan.output.path, plan.backup.path)} + async with factory() as session: + assert await recovery.recover_conversion(session, operation) == "review" + assert (await session.scalar(select(LibraryConversion))).active + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize("which", ["original", "output", "backup", "output_stage", "backup_stage"]) +async def test_conversion_reservation_blocks_rename(identity_probe_db, tmp_path, which): + from pullbox.services.library_rename_service import rename_library_entry + + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + await session.commit() + path = getattr(plan, which) + if not isinstance(path, Path): + path = path.path + async with factory() as session: + with pytest.raises(ValidationError, match="conversion"): + await rename_library_entry( + session, source=path, target=path.with_name("renamed"), kind="file" + ) + async with factory() as session: + with pytest.raises(ValidationError, match="conversion"): + await rename_library_entry( + session, + source=path.parent, + target=path.parent.with_name("renamed"), + kind="folder", + ) + + +@pytest.mark.parametrize("stage", ["convert", "backup"]) +async def test_cancel_private_preparation_preserves_source( + identity_probe_db, tmp_path, monkeypatch, stage +): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + original = source.read_bytes() + entered = asyncio.Event() + exited = asyncio.Event() + + async def slow(*args, **kwargs): + entered.set() + try: + await asyncio.Event().wait() + finally: + exited.set() + + monkeypatch.setattr( + files, + "convert_file_interruptible" if stage == "convert" else "transfer_file_interruptible", + slow, + ) + async with factory() as session: + task = asyncio.create_task(convert(session, source, tmp_path)) + await asyncio.wait_for(entered.wait(), 3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 3) + assert exited.is_set() + assert source.read_bytes() == original + assert not source.with_suffix(".cbz").exists() + assert not list(tmp_path.rglob(".pullbox-conversion-*")) + + +async def test_concurrent_intents_serialize(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + await session.commit() + async with factory() as session: + with pytest.raises(ValidationError, match="conversion"): + await recovery.record_conversion(session, plan, uuid4()) + + +async def test_changed_source_during_preparation_never_publishes( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + transfer = files.transfer_file_interruptible + + async def change(*args, **kwargs): + result = await transfer(*args, **kwargs) + source.write_bytes(b"changed externally") + return result + + monkeypatch.setattr(files, "transfer_file_interruptible", change) + async with factory() as session: + with pytest.raises(ValidationError, match="changed"): + await convert(session, source, tmp_path) + assert source.read_bytes() == b"changed externally" + assert not source.with_suffix(".cbz").exists() + + +async def test_cancel_publication_joins_worker_and_leaves_recoverable_files( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + original = source.read_bytes() + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + publish = recovery.publish + + def gated_publish(plan): + entered.set() + assert release.wait(5) + publish(plan) + finished.set() + + monkeypatch.setattr(recovery, "publish", gated_publish) + async with factory() as session: + task = asyncio.create_task(convert(session, source, tmp_path)) + assert await asyncio.to_thread(entered.wait, 5) + task.cancel() + await asyncio.sleep(0) + assert not task.done(), "Cancellation must join the publishing worker" + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + assert finished.is_set() + assert source.read_bytes() == original + assert source.with_suffix(".cbz").is_file() + async with factory() as session: + assert await recovery.recover_library_conversions(session) == 1 + assert not source.exists() + + +@pytest.mark.parametrize("which", ["output", "backup"]) +async def test_racing_destinations_are_never_replaced(identity_probe_db, tmp_path, which): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (source, _, plan, operation): + async with factory() as session: + await recovery.record_conversion(session, plan, operation) + await session.commit() + collision = getattr(plan, which).path + collision.write_bytes(b"racing existing file") + with pytest.raises(FileExistsError): + await recovery.publish_conversion(session, operation) + await session.rollback() + assert collision.read_bytes() == b"racing existing file" + assert source.is_file() + + +async def test_preparation_does_not_hold_database_writer(identity_probe_db, tmp_path, monkeypatch): + from pullbox.services.library_mutation_coordination import lock_file_mutation_admission + + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + converter = files.convert_file_interruptible + + async def assert_unlocked(*args, **kwargs): + async with factory() as session: + await asyncio.wait_for(lock_file_mutation_admission(session), 2) + await session.rollback() + return await converter(*args, **kwargs) + + monkeypatch.setattr(files, "convert_file_interruptible", assert_unlocked) + async with factory() as session: + await convert(session, source, tmp_path) + + +async def test_conversion_retains_reader_registration_if_cleanup_fails( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, file_id = await seed(factory, tmp_path) + remove = recovery.remove_original + + async def failed_cleanup(*args): + raise OSError("storage unavailable") + + monkeypatch.setattr(recovery, "remove_original", failed_cleanup) + async with factory() as session: + with pytest.raises(ValidationError): + await convert(session, source, tmp_path) + assert source.is_file() + async with factory() as session: + assert (await session.get(LibraryFile, file_id)).file_path == str( + source.with_suffix(".cbz") + ) + assert (await session.scalar(select(LibraryConversion))).state == "registered" + monkeypatch.setattr(recovery, "remove_original", remove) + async with factory() as session: + assert await recovery.recover_library_conversions(session) == 1 + assert not source.exists() + + +async def test_registered_nested_root_is_not_mistaken_for_ambiguity(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + child = tmp_path / "series" + child.mkdir() + source, _ = await seed(factory, child) + async with factory() as session: + session.add(LibraryRoot(name="Parent", path=str(tmp_path), enabled=True)) + await session.commit() + result = await convert(session, source, tmp_path) + assert Path(result.target_path).is_file() + + +async def test_changed_root_policy_during_preparation_rejects_publication( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + converter = files.convert_file_interruptible + + async def change_policy(*args, **kwargs): + result = await converter(*args, **kwargs) + async with factory() as session: + root = await session.scalar(select(LibraryRoot)) + root.allow_managed_writes = False + await session.commit() + return result + + monkeypatch.setattr(files, "convert_file_interruptible", change_policy) + async with factory() as session: + with pytest.raises(ValidationError, match="managed"): + await convert(session, source, tmp_path) + assert source.is_file() + assert not source.with_suffix(".cbz").exists() + + +async def test_success_hashes_recovery_evidence_only_once(identity_probe_db, tmp_path, monkeypatch): + _, factory, _ = identity_probe_db + source, _ = await seed(factory, tmp_path) + inspect = recovery.inspect_conversion + calls = 0 + + async def count(plan): + nonlocal calls + calls += 1 + return await inspect(plan) + + monkeypatch.setattr(recovery, "inspect_conversion", count) + async with factory() as session: + await convert(session, source, tmp_path) + assert calls == 1, "Cleanup must reuse unchanged evidence, not reread entire large archives" + + +async def test_long_source_name_keeps_a_recoverable_backup(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + source, file_id = await seed(factory, tmp_path) + long_source = source.with_name("A" * 240 + ".cbr") + source.rename(long_source) + async with factory() as session: + file = await session.get(LibraryFile, file_id) + file.file_path = str(long_source) + file.file_name = long_source.name + await session.commit() + result = await convert(session, long_source, tmp_path) + assert Path(result.original_trash_path).name == long_source.name + assert Path(result.original_trash_path).is_file() + assert Path(result.target_path).is_file() diff --git a/tests/integration/metadata_identity/test_library_conversion_migration.py b/tests/integration/metadata_identity/test_library_conversion_migration.py new file mode 100644 index 00000000..8828b400 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_conversion_migration.py @@ -0,0 +1,56 @@ +"""Conversion journal upgrade/downgrade and orphan preservation on both engines.""" + +import importlib.util +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, select, update + +from pullbox.models.library_conversion import LibraryConversion +from pullbox.services.library_conversion_recovery import record_conversion +from tests.integration.metadata_identity.test_library_conversion_lifecycle import prepared + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py" + ) + spec = importlib.util.spec_from_file_location("library_conversion_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_conversion_migration_roundtrip(identity_probe_db): + engine, _, _ = identity_probe_db + table = LibraryConversion.__table__ + async with engine.begin() as connection: + await connection.run_sync(table.drop) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + keys = await connection.run_sync(lambda conn: inspect(conn).get_foreign_keys(table.name)) + assert keys[0]["options"]["ondelete"] == "SET NULL" + await connection.run_sync(lambda conn: revision(conn).downgrade()) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + + +@pytest.mark.parametrize("state", ["intended", "registered", "complete", "review"]) +async def test_conversion_downgrade_retains_evidence(identity_probe_db, tmp_path, state): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (_, _, plan, operation): + async with factory() as session: + await record_conversion(session, plan, operation) + await session.execute( + update(LibraryConversion).values(state=state, active=state != "complete") + ) + await session.commit() + with pytest.raises(RuntimeError, match="conversion evidence"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + async with factory() as session: + assert ( + await session.scalar(select(LibraryConversion.plan_json)) == plan.model_dump_json() + ) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 11b1bff8..95945f9f 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["a8u9v0w1x234"] + assert script.get_heads() == ["b9v0w1x2y345"] + assert script.get_revision("b9v0w1x2y345").down_revision == "a8u9v0w1x234" assert script.get_revision("a8u9v0w1x234").down_revision == "z7t8u9v0w123" assert script.get_revision("z7t8u9v0w123").down_revision == "y6s7t8u9v012" assert script.get_revision("y6s7t8u9v012").down_revision == "x5r6s7t8u901" diff --git a/tests/integration/test_reader_file_continuity.py b/tests/integration/test_reader_file_continuity.py index 1f8aa98c..835ce5ed 100644 --- a/tests/integration/test_reader_file_continuity.py +++ b/tests/integration/test_reader_file_continuity.py @@ -209,13 +209,14 @@ async def test_conversion_to_cbz_preserves_reader_state_and_issue_identity( before = _state_values(state) source = Path(library_file.file_path) - async def preserving_converter(source_path: Path, _target_format: str) -> Path: - target = source_path.with_suffix(".cbz") - await asyncio.to_thread(shutil.copy2, source_path, target) - return target + async def preserving_converter( + source_path: Path, _target_format: str, *, output_path: Path + ) -> Path: + await asyncio.to_thread(shutil.copy2, source_path, output_path) + return output_path monkeypatch.setattr( - "pullbox.services.library_convert_service.convert_file", + "pullbox.services.library_conversion_files.convert_file_interruptible", preserving_converter, ) diff --git a/tests/unit/test_app_lifespan.py b/tests/unit/test_app_lifespan.py index c46b03ee..fb1deafe 100644 --- a/tests/unit/test_app_lifespan.py +++ b/tests/unit/test_app_lifespan.py @@ -198,6 +198,10 @@ async def no_op_async(*_args: object, **_kwargs: object) -> None: async def no_op_count(*_args: object, **_kwargs: object) -> int: return 0 + async def recover_conversions(_session: object) -> int: + _LIFESPAN_EVENTS.append("conversion_recovery_completed") + return 1 + def session_factory() -> _FakeSession: return _FakeSession() @@ -237,6 +241,10 @@ async def start_airdcpp_registry(_factory: object, *, enabled: bool) -> object | monkeypatch.setattr(app, "_run_debug_logging_expiry_enforcer", neverending_debug_enforcer) monkeypatch.setattr(app, "get_event_bus", lambda: event_bus) monkeypatch.setattr(app, "get_session_factory", lambda: session_factory) + monkeypatch.setattr( + "pullbox.services.library_conversion_recovery.recover_library_conversions", + recover_conversions, + ) monkeypatch.setattr(app, "load_system_config_values", load_config_values) monkeypatch.setattr(app, "get_scheduler", lambda: scheduler) monkeypatch.setattr(app, "dispose_engine", no_op_async) @@ -395,9 +403,12 @@ async def test_import_recovery_completes_before_scheduler_can_start( async with patched_lifespan.app.lifespan(FastAPI()): assert "import_recovery_completed" in _LIFESPAN_EVENTS assert "scheduler_started" in _LIFESPAN_EVENTS - assert _LIFESPAN_EVENTS.index("import_recovery_completed") < _LIFESPAN_EVENTS.index( - "scheduler_started" - ) + assert _LIFESPAN_EVENTS.index("conversion_recovery_completed") < _LIFESPAN_EVENTS.index( + "import_recovery_completed" + ) + assert _LIFESPAN_EVENTS.index("import_recovery_completed") < _LIFESPAN_EVENTS.index( + "scheduler_started" + ) @pytest.mark.asyncio diff --git a/tests/unit/test_library_convert_service.py b/tests/unit/test_library_convert_service.py index 7e6b5316..86bcd402 100644 --- a/tests/unit/test_library_convert_service.py +++ b/tests/unit/test_library_convert_service.py @@ -14,28 +14,29 @@ @pytest.mark.asyncio -async def test_convert_library_file_removes_converted_artifact_when_trash_move_fails( +async def test_convert_library_file_discards_private_artifact_when_backup_fails( sec_db, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: # type: ignore[no-untyped-def] from pullbox.core.exceptions import ValidationError from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot + from pullbox.services import library_conversion_files as files from pullbox.services import library_convert_service as service source = tmp_path / "Convert Me.cbr" converted = tmp_path / "Convert Me.cbz" source.write_text("source", encoding="utf-8") - async def convert_file(_source: Path, _target_format: str) -> Path: - converted.write_text("converted", encoding="utf-8") - return converted + async def convert_file(_source: Path, _target_format: str, *, output_path: Path) -> Path: + output_path.write_text("converted", encoding="utf-8") + return output_path - def fail_move(*_args: object, **_kwargs: object) -> Path: + async def fail_move(*_args: object, **_kwargs: object) -> Path: raise FileExistsError("trash collision") - monkeypatch.setattr(service, "convert_file", convert_file) - monkeypatch.setattr(service, "move_file_to_utility_trash", fail_move) + monkeypatch.setattr(files, "convert_file_interruptible", convert_file) + monkeypatch.setattr(files, "transfer_file_interruptible", fail_move) async with sec_db() as session: root = LibraryRoot(name="Comics", path=str(tmp_path), enabled=True) @@ -70,39 +71,30 @@ def fail_move(*_args: object, **_kwargs: object) -> Path: @pytest.mark.asyncio -async def test_convert_library_file_restores_original_when_later_step_fails( +async def test_convert_library_file_retains_original_and_journal_when_registration_fails( sec_db, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: # type: ignore[no-untyped-def] from pullbox.core.exceptions import ValidationError from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot + from pullbox.services import library_conversion_files as files from pullbox.services import library_convert_service as service + from pullbox.services.library_conversion_recovery import recover_library_conversions source = tmp_path / "Restore Me.cbr" converted = tmp_path / "Restore Me.cbz" - trash_path = tmp_path / ".trash" / source.name source.write_text("source", encoding="utf-8") - async def convert_file(_source: Path, _target_format: str) -> Path: - converted.write_text("converted", encoding="utf-8") - return converted - - def move_to_trash(path: Path, _trash_dir: Path, **_kwargs: object) -> Path: - trash_path.parent.mkdir(parents=True, exist_ok=True) - path.replace(trash_path) - return trash_path - - def restore_from_trash(path: Path, destination: Path, **_kwargs: object) -> None: - path.replace(destination) - raise RuntimeError("restore hook failed after moving original") + async def convert_file(_source: Path, _target_format: str, *, output_path: Path) -> Path: + output_path.write_text("converted", encoding="utf-8") + return output_path async def fail_sync(*_args: object, **_kwargs: object) -> None: raise RuntimeError("database write failed") - monkeypatch.setattr(service, "convert_file", convert_file) - monkeypatch.setattr(service, "move_file_to_utility_trash", move_to_trash) - monkeypatch.setattr(service, "restore_file_from_utility_trash", restore_from_trash) + sync = service._sync_converted_file_record + monkeypatch.setattr(files, "convert_file_interruptible", convert_file) monkeypatch.setattr(service, "_sync_converted_file_record", fail_sync) async with sec_db() as session: @@ -129,7 +121,12 @@ async def fail_sync(*_args: object, **_kwargs: object) -> None: ) assert source.exists() is True - assert trash_path.exists() is False + assert converted.exists() is True + assert len(list((tmp_path / ".trash").rglob("*.cbr"))) == 1 + monkeypatch.setattr(service, "_sync_converted_file_record", sync) + assert await recover_library_conversions(session) == 1 + assert not source.exists() + assert converted.read_text() == "converted" @pytest.mark.asyncio @@ -145,13 +142,14 @@ async def test_convert_library_file_rejects_referenced_source_before_conversion( LibraryFileStorageMode, LibraryRoot, ) + from pullbox.services import library_conversion_files as files from pullbox.services import library_convert_service as service source = tmp_path / "Referenced.cbr" original = b"user-owned comic" source.write_bytes(original) convert = pytest.fail - monkeypatch.setattr(service, "convert_file", convert) + monkeypatch.setattr(files, "convert_file_interruptible", convert) async with sec_db() as session: root = LibraryRoot(name="Comics", path=str(tmp_path), enabled=True) From 6f82f647c30c8ef77ccb974211b122ffa8e91512 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 10:19:31 -0700 Subject: [PATCH 74/97] feat(metadata): support paired PDF conversion --- docs/development/ARCHITECTURE_OVERVIEW.md | 13 +- scripts/verify_container_security_runtime.py | 76 ++++ src/pullbox/core/metadata_archive_source.py | 19 +- src/pullbox/core/metadata_pdf_source.py | 208 +++++++++++ .../services/archive_metadata_writing.py | 8 + .../executors/archive_metadata_staging.py | 7 +- .../utilities/executors/archive_subprocess.py | 66 +++- tests/unit/test_pdf_metadata_writing.py | 325 ++++++++++++++++++ .../test_verify_container_security_runtime.py | 16 + .../test_archive_metadata_staging.py | 39 +++ tests/utilities/test_archive_subprocess.py | 46 +++ 11 files changed, 810 insertions(+), 13 deletions(-) create mode 100644 src/pullbox/core/metadata_pdf_source.py create mode 100644 tests/unit/test_pdf_metadata_writing.py diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index f4ffa8de..637285e2 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -526,9 +526,14 @@ sources without an intermediate CBZ. RAR/TAR members are streamed; solid 7z is decompressed once into bounded, private numbered spools under the owner's staging directory, never archive-supplied filesystem paths. Special/encrypted/split members and unsafe or oversized payloads fail closed. Cancellation checks cover -extraction, copying and verification. Production conversion caller migration, -cross-filesystem publication/trash recovery and paired PDF conversion remain -outstanding; legacy converters have not switched to this boundary. +extraction, copying and verification. PDF input uses bounded, private page spools +under the same owner workspace. Poppler inspects the page count, encryption and +dimensions before rendering, then renders one page at a time at the requested +quality without silently downscaling. Output bytes, page pixels and helper +deadlines are bounded; cancellation kills/reaps the helper before cleanup. +Both XML documents are still written during the single CBZ construction. +Production conversion callers have not yet switched their metadata content +policy to this paired boundary. `utilities/executors/archive_metadata_staging.py` hands paired CBZ work to the interruptible archive worker without giving it a final library destination. @@ -542,6 +547,8 @@ and revalidate the supplied fingerprints before publication. This context is not a durable journal, permission to mutate reference files or a filesystem lock. The shared supervisor reaps workers after task cancellation, including during spawn and repeated cancellation, and shields pipe draining during kill escalation. +Paired workers on POSIX run in isolated process groups, unwind helper cleanup on +SIGTERM, and terminate remaining helpers after cancellation or abnormal exit. Legacy operations still publish inside their workers; they have not migrated to the staged owner-publication contract. Production binding, journal recovery and consumer migration remain required. diff --git a/scripts/verify_container_security_runtime.py b/scripts/verify_container_security_runtime.py index ed850a4b..c369bf71 100644 --- a/scripts/verify_container_security_runtime.py +++ b/scripts/verify_container_security_runtime.py @@ -2,10 +2,17 @@ from __future__ import annotations +import asyncio +import io import pyexpat +import tempfile +import zipfile from importlib.util import find_spec +from pathlib import Path from xml.etree import ElementTree +from PIL import Image + from pullbox.core.xml_security import parse_untrusted_xml MINIMUM_EXPAT_VERSION = (2, 8, 1) @@ -32,6 +39,73 @@ def verify_utf16_xml_boundary() -> None: raise SystemExit("Container must reject malformed UTF-16 XML before Expat parsing") +def verify_paired_pdf_boundary() -> None: + """Require real bounded Poppler rendering and private paired staging in the image.""" + from pullbox.core.metadata_identity import ( + ExternalIdentityRef, + IdentityNamespace, + MetadataEntityKind, + ) + from pullbox.core.metroninfo import parse_metroninfo + from pullbox.core.metroninfo_schema import validate_metroninfo_xml + from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + series = MetadataSnapshot( + entity_kind=MetadataEntityKind.SERIES, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42"), + ), + values=MetadataValues(title="Runtime fixture"), + ) + issue = MetadataSnapshot( + entity_kind=MetadataEntityKind.ISSUE, + identities=( + ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.ISSUE, "7"), + ), + values=MetadataValues(issue_number_text="50-x"), + ) + + async def probe(directory: Path) -> None: + source = directory / "fixture.pdf" + with ( + Image.new("RGB", (72, 90), "red") as first, + Image.new("RGB", (72, 90), "blue") as second, + ): + first.save(source, format="PDF", save_all=True, append_images=[second]) + original = source.read_bytes() + async with stage_cbz_metadata_interruptible( + source, directory, series, issue, max_uncompressed_bytes=2_000_000 + ) as staged: + staged.check_unchanged() + with zipfile.ZipFile(staged.path) as archive: + assert archive.testzip() is None + assert archive.namelist() == [ + "page_0000.jpg", + "page_0001.jpg", + "ComicInfo.xml", + "MetronInfo.xml", + ] + ci = parse_untrusted_xml(archive.read("ComicInfo.xml")) + mi_payload = archive.read("MetronInfo.xml") + validate_metroninfo_xml(mi_payload) + mi = parse_metroninfo(mi_payload) + assert ci.findtext("Series") == mi.series == "Runtime fixture" + assert ci.findtext("Number") == mi.number == "50-x" + for name, channel in (("page_0000.jpg", 0), ("page_0001.jpg", 2)): + with Image.open(io.BytesIO(archive.read(name))) as page: + assert page.size == (200, 250) + pixel = page.getpixel((100, 100)) + assert pixel[channel] > 200 and pixel[1] < 20 + assert source.read_bytes() == original + assert list(directory.iterdir()) == [source] + + with tempfile.TemporaryDirectory(prefix="pullbox-pdf-probe-") as directory: + asyncio.run(probe(Path(directory))) + + def main() -> None: """Run all container runtime security assertions.""" for package in ("safety", "nltk"): @@ -42,6 +116,8 @@ def main() -> None: print(f"Container Expat runtime verified: {pyexpat.EXPAT_VERSION}") verify_utf16_xml_boundary() print("Container malformed UTF-16 XML guard verified") + verify_paired_pdf_boundary() + print("Container paired PDF staging and source-preservation boundary verified") if __name__ == "__main__": diff --git a/src/pullbox/core/metadata_archive_source.py b/src/pullbox/core/metadata_archive_source.py index 4b191f22..04b2be05 100644 --- a/src/pullbox/core/metadata_archive_source.py +++ b/src/pullbox/core/metadata_archive_source.py @@ -11,12 +11,15 @@ from contextlib import AbstractContextManager, ExitStack, contextmanager from dataclasses import dataclass from pathlib import Path -from typing import IO, BinaryIO +from typing import IO, TYPE_CHECKING, BinaryIO from py7zr.io import Py7zIO, WriterFactory from pullbox.core.file_safety import FileSafetyError +if TYPE_CHECKING: + from pullbox.core.metadata_pdf_source import PdfQuality + @dataclass class MetadataArchiveSource: @@ -47,6 +50,8 @@ def open_metadata_archive( validate: Callable[[list[zipfile.ZipInfo]], None], check_cancelled: Callable[[], None], progress: Callable[[int, int], None], + pdf_quality: "PdfQuality" = "medium", + pdf_progress: Callable[[int, int], None] | None = None, ) -> Iterator[MetadataArchiveSource]: """Never extract archive-supplied paths; solid 7z uses private numbered spools. @@ -67,6 +72,18 @@ def open_metadata_archive( result = _open_7z( stream, stack, limit, scratch_parent, validate, check_cancelled, progress ) + elif header.startswith(b"%PDF-"): + from pullbox.core.metadata_pdf_source import open_pdf_pages + + result = open_pdf_pages( + stream, + stack, + limit=limit, + scratch_parent=scratch_parent, + quality=pdf_quality, + check_cancelled=check_cancelled, + progress=pdf_progress or progress, + ) elif header[257:262] == b"ustar" or path.suffix.casefold() in {".cbt", ".tar"}: result = _open_tar(stream, stack, limit, check_cancelled) else: diff --git a/src/pullbox/core/metadata_pdf_source.py b/src/pullbox/core/metadata_pdf_source.py new file mode 100644 index 00000000..750eeb05 --- /dev/null +++ b/src/pullbox/core/metadata_pdf_source.py @@ -0,0 +1,208 @@ +"""Bounded PDF page spools for the single-construction paired archive writer.""" + +import io +import math +import os +import queue +import re +import subprocess +import tempfile +import threading +import time +from collections.abc import Callable +from contextlib import ExitStack +from pathlib import Path +from typing import BinaryIO, Literal + +from PIL import Image + +from pullbox.core.file_safety import FileSafetyError +from pullbox.core.metadata_archive_source import MetadataArchiveSource, _entry + +type PdfQuality = Literal["high", "medium", "low"] +_PRESETS = {"high": (300, "png", 0), "medium": (200, "jpg", 90), "low": (150, "jpg", 80)} +_MAX_PAGES = 10_000 +_MAX_PAGE_PIXELS = 80_000_000 +_INFO_BYTES = 4 * 1024 * 1024 +_CHUNK_BYTES = 64 * 1024 + + +def _run_bounded( + command: list[str], + output: BinaryIO, + *, + limit: int, + timeout: float, + check_cancelled: Callable[[], None], +) -> int: + """Bound pipe memory/disk and reap Poppler even on callback failure or timeout.""" + check_cancelled() + try: + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={**os.environ, "LC_ALL": "C"}, + ) + except OSError as exc: + raise FileSafetyError("PDF renderer is unavailable") from exc + assert process.stdout is not None + pipe = process.stdout + chunks: queue.Queue[bytes | OSError | None] = queue.Queue(maxsize=2) + stop = threading.Event() + + def send(chunk: bytes | OSError | None) -> None: + while not stop.is_set(): + try: + chunks.put(chunk, timeout=0.05) + return + except queue.Full: + continue + + def read() -> None: + try: + while not stop.is_set(): + chunk = pipe.read(_CHUNK_BYTES) + if not chunk: + break + send(chunk) + except OSError as exc: + send(exc) + finally: + send(None) + + reader = threading.Thread(target=read, name="pdf-output", daemon=True) + reader.start() + deadline = time.monotonic() + timeout + written = 0 + eof = False + try: + while not eof or process.poll() is None: + check_cancelled() + if time.monotonic() >= deadline: + raise FileSafetyError("PDF rendering exceeded its time limit") + if eof: + stop.wait(0.05) + continue + try: + chunk = chunks.get(timeout=0.05) + except queue.Empty: + continue + if chunk is None: + eof = True + elif isinstance(chunk, OSError): + raise FileSafetyError("PDF renderer output could not be read") from chunk + else: + if written + len(chunk) > limit: + raise FileSafetyError("PDF rendered size exceeds limit") + output.write(chunk) + written += len(chunk) + if process.returncode != 0: + raise FileSafetyError("PDF could not be inspected or rendered") + check_cancelled() + return written + finally: + stop.set() + if process.poll() is None: + process.kill() + process.wait() + reader.join() + pipe.close() + + +def open_pdf_pages( + stream: BinaryIO, + stack: ExitStack, + *, + limit: int, + scratch_parent: Path, + quality: PdfQuality, + check_cancelled: Callable[[], None], + progress: Callable[[int, int], None], +) -> MetadataArchiveSource: + """Render one page at a time; never reopen the caller's mutable source path.""" + dpi, extension, jpeg_quality = _PRESETS[quality] + directory = Path( + stack.enter_context(tempfile.TemporaryDirectory(prefix=".pullbox-pdf-", dir=scratch_parent)) + ) + private_input = directory / "input.pdf" + with private_input.open("xb") as copied: + total = 0 + while chunk := stream.read(_CHUNK_BYTES): + check_cancelled() + total += len(chunk) + if total > limit: + raise FileSafetyError("PDF source size exceeds limit") + copied.write(chunk) + + info = io.BytesIO() + _run_bounded( + ["pdfinfo", "-f", "1", "-l", str(_MAX_PAGES), str(private_input)], + info, + limit=_INFO_BYTES, + timeout=15, + check_cancelled=check_cancelled, + ) + details = info.getvalue().decode("utf-8", errors="replace") + counts = re.findall(r"^Pages:[ \t]+(\d+)[ \t]*$", details, re.MULTILINE) + if len(counts) != 1 or len(counts[0]) > 5: + raise FileSafetyError("PDF page count could not be verified") + count = int(counts[0]) + if count < 1 or count > _MAX_PAGES: + raise FileSafetyError("PDF page count exceeds limit or is empty") + if re.findall(r"^Encrypted:[ \t]+(.+)$", details, re.MULTILINE) != ["no"]: + raise FileSafetyError("Encrypted PDF cannot be converted safely") + sizes = re.findall(r"^Page\s+(\d+) size:\s+(\S+) x (\S+) pts.*$", details, re.MULTILINE) + if len(sizes) != count: + raise FileSafetyError("PDF page dimensions could not be verified") + dimensions = [] + for expected, (number, width, height) in enumerate(sizes, 1): + try: + pixels = (float(width) * dpi / 72, float(height) * dpi / 72) + if int(number) != expected or any(not math.isfinite(p) or p <= 0 for p in pixels): + raise ValueError + size = (math.ceil(pixels[0]), math.ceil(pixels[1])) + except (ValueError, OverflowError) as exc: + raise FileSafetyError("PDF page dimensions could not be verified") from exc + # pdfinfo rounds physical sizes; allow one raster pixel without widening the cap. + if (size[0] + 1) * (size[1] + 1) > _MAX_PAGE_PIXELS: + raise FileSafetyError("PDF page pixel count exceeds limit") + dimensions.append(size) + + progress(0, count) + entries = [] + written = 0 + for number, expected_size in enumerate(dimensions, 1): + check_cancelled() + name = f"page_{number - 1:04d}.{extension}" + path = directory / name + command = ["pdftoppm", "-f", str(number), "-l", str(number), "-singlefile", "-r", str(dpi)] + command += ( + ["-png"] if extension == "png" else ["-jpeg", "-jpegopt", f"quality={jpeg_quality}"] + ) + command.append(str(private_input)) + with path.open("xb") as output: + page_bytes = _run_bounded( + command, output, limit=limit - written, timeout=120, check_cancelled=check_cancelled + ) + try: + with Image.open(path) as image: + if image.width * image.height > _MAX_PAGE_PIXELS or not any( + all( + abs(actual - expected) <= 1 + for actual, expected in zip(image.size, size, strict=True) + ) + for size in (expected_size, expected_size[::-1]) + ): + raise FileSafetyError("PDF rendered page dimensions disagree with inspection") + image.verify() + except (OSError, ValueError, Image.DecompressionBombError) as exc: + raise FileSafetyError("PDF rendered page could not be verified") from exc + written += page_bytes + entries.append(_entry(name, page_bytes)) + progress(number, count) + private_input.unlink() + return MetadataArchiveSource( + entries, lambda entry: (directory / entry.filename).open("rb"), stack.close + ) diff --git a/src/pullbox/services/archive_metadata_writing.py b/src/pullbox/services/archive_metadata_writing.py index 18e9c51f..2946ce95 100644 --- a/src/pullbox/services/archive_metadata_writing.py +++ b/src/pullbox/services/archive_metadata_writing.py @@ -20,6 +20,7 @@ ) from pullbox.core.metadata_archive_source import MetadataArchiveSource, open_metadata_archive from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_pdf_source import PdfQuality from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_rendering import render_archive_metadata @@ -45,6 +46,7 @@ def write_cbz_metadata( previous_series: MetadataSnapshot | None = None, previous_issue: MetadataSnapshot | None = None, replace_managed: bool = False, + pdf_quality: PdfQuality = "medium", ) -> bool: """Write a verified pair in one CBZ construction, off the event loop. @@ -56,6 +58,8 @@ def write_cbz_metadata( """ if isinstance(max_uncompressed_bytes, bool) or max_uncompressed_bytes <= 0: raise ValueError("Archive budget must be positive") + if pdf_quality not in {"high", "medium", "low"}: + raise ValueError("Invalid PDF quality preset") source_path = source_path.absolute() target_path = target_path.absolute() refresh = source_path == target_path @@ -95,6 +99,10 @@ def write_cbz_metadata( progress=lambda current, total: _progress( progress_callback, check_cancelled, "extracting", current, total ), + pdf_quality=pdf_quality, + pdf_progress=lambda current, total: _progress( + progress_callback, check_cancelled, "rendering", current, total, "pages" + ), ) as source: entries = source.entries files = read_open_metadata_members(entries, source.open) diff --git a/src/pullbox/utilities/executors/archive_metadata_staging.py b/src/pullbox/utilities/executors/archive_metadata_staging.py index 32830c6b..1ff61bb5 100644 --- a/src/pullbox/utilities/executors/archive_metadata_staging.py +++ b/src/pullbox/utilities/executors/archive_metadata_staging.py @@ -1,4 +1,4 @@ -"""Private paired CBZ output from ZIP/RAR/7z/TAR for journal-owning workflows.""" +"""Private paired CBZ output from archives/PDF for journal-owning workflows.""" import os import shutil @@ -16,6 +16,7 @@ from pullbox.core.file_safety import FileSafetyError from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.core.metadata_pdf_source import PdfQuality from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_rendering import ArchiveMetadataRenderError from pullbox.services.archive_metadata_writing import write_cbz_metadata @@ -45,6 +46,7 @@ class _StagingRequest(BaseModel): previous_series: MetadataSnapshot | None = None previous_issue: MetadataSnapshot | None = None replace_managed: bool = Field(default=False, strict=True) + pdf_quality: PdfQuality = "medium" class _StagingResult(BaseModel): @@ -87,6 +89,7 @@ async def stage_cbz_metadata_interruptible( previous_series: MetadataSnapshot | None = None, previous_issue: MetadataSnapshot | None = None, replace_managed: bool = False, + pdf_quality: PdfQuality = "medium", ) -> AsyncIterator[StagedArchiveMetadata]: """Stage a verified pair, then yield to the independently authorized owner. @@ -111,6 +114,7 @@ async def stage_cbz_metadata_interruptible( previous_series=previous_series, previous_issue=previous_issue, replace_managed=replace_managed, + pdf_quality=pdf_quality, ) encoded = request.model_dump_json().encode("utf-8") if len(encoded) > _MAX_REQUEST_BYTES: @@ -245,6 +249,7 @@ def progress(stage: str, current: int, total: int, unit: str) -> None: previous_series=request.previous_series, previous_issue=request.previous_issue, replace_managed=request.replace_managed, + pdf_quality=request.pdf_quality, ) _check_fingerprint(request.source_path, request.source_fingerprint) except ArchiveMetadataRenderError: diff --git a/src/pullbox/utilities/executors/archive_subprocess.py b/src/pullbox/utilities/executors/archive_subprocess.py index cd04904f..12ce7ee3 100644 --- a/src/pullbox/utilities/executors/archive_subprocess.py +++ b/src/pullbox/utilities/executors/archive_subprocess.py @@ -17,17 +17,21 @@ import json import os import shutil +import signal import sys import tempfile import time from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast import structlog from pullbox.core.exceptions import JobCancelledError, JobPausedError +if TYPE_CHECKING: + from types import FrameType + ControlCheck = Callable[[], Awaitable[None]] ProgressCallback = Callable[[str, int, int, str], Awaitable[None] | None] _ArchiveOperation = Literal["convert", "transfer", "embed", "materialize_embed", "paired_stage"] @@ -256,6 +260,7 @@ async def _run_archive_operation( ) -> dict[str, Any]: stdout: bytes = b"" stderr: bytes = b"" + isolated_group = operation == "paired_stage" and os.name != "nt" spawn_task = asyncio.create_task( asyncio.create_subprocess_exec( sys.executable, @@ -266,12 +271,15 @@ async def _run_archive_operation( json.dumps(payload), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + start_new_session=isolated_group, ) ) try: proc = await asyncio.shield(spawn_task) except BaseException: - await _await_worker_cleanup(asyncio.create_task(_cleanup_spawned_worker(spawn_task))) + await _await_worker_cleanup( + asyncio.create_task(_cleanup_spawned_worker(spawn_task, isolated_group=isolated_group)) + ) raise communicate_task = asyncio.create_task(proc.communicate()) last_reported_transfer = -1 @@ -339,7 +347,9 @@ async def _run_archive_operation( except BaseException as exc: was_running = proc.returncode is None await _await_worker_cleanup( - asyncio.create_task(_terminate_worker_process(proc, communicate_task)) + asyncio.create_task( + _terminate_worker_process(proc, communicate_task, isolated_group=isolated_group) + ) ) if was_running and isinstance( exc, (JobCancelledError, JobPausedError, asyncio.CancelledError) @@ -347,6 +357,16 @@ async def _run_archive_operation( _cleanup_operation_paths(operation, payload, effective_cleanup_paths) raise + if isolated_group: + cleanup_task = asyncio.create_task( + _terminate_worker_process(proc, communicate_task, isolated_group=True) + ) + try: + await asyncio.shield(cleanup_task) + except BaseException: + await _await_worker_cleanup(cleanup_task) + raise + if proc.returncode != 0: _cleanup_operation_paths(operation, payload, effective_cleanup_paths) _raise_worker_error(operation, payload, stdout, stderr, returncode=proc.returncode) @@ -389,14 +409,19 @@ async def _dispatch_progress_callback( async def _terminate_worker_process( proc: asyncio.subprocess.Process, communicate_task: asyncio.Task[tuple[bytes, bytes]], + *, + isolated_group: bool = False, ) -> None: - if proc.returncode is not None: + if proc.returncode is not None and not isolated_group: with contextlib.suppress(Exception): await communicate_task return with contextlib.suppress(ProcessLookupError): - proc.terminate() + if isolated_group: + os.killpg(proc.pid, signal.SIGTERM) + else: + proc.terminate() try: await asyncio.wait_for( asyncio.shield(communicate_task), timeout=_PROCESS_TERMINATE_TIMEOUT_SECONDS @@ -404,14 +429,26 @@ async def _terminate_worker_process( return except TimeoutError: with contextlib.suppress(ProcessLookupError): - proc.kill() + if isolated_group: + os.killpg(proc.pid, signal.SIGKILL) + else: + proc.kill() with contextlib.suppress(Exception): await communicate_task + finally: + if isolated_group: + # A child can close inherited pipes and still outlive its worker. + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) -async def _cleanup_spawned_worker(task: asyncio.Task[asyncio.subprocess.Process]) -> None: +async def _cleanup_spawned_worker( + task: asyncio.Task[asyncio.subprocess.Process], *, isolated_group: bool = False +) -> None: proc = await task - await _terminate_worker_process(proc, asyncio.create_task(proc.communicate())) + await _terminate_worker_process( + proc, asyncio.create_task(proc.communicate()), isolated_group=isolated_group + ) async def _await_worker_cleanup(task: asyncio.Task[None]) -> None: @@ -559,6 +596,11 @@ def _worker_main(argv: list[str]) -> int: operation = argv[1] payload = json.loads(argv[2]) + previous_handler = ( + signal.signal(signal.SIGTERM, _cancel_paired_worker) + if operation == "paired_stage" and os.name != "nt" + else None + ) try: if operation == "convert": @@ -585,11 +627,19 @@ def _worker_main(argv: list[str]) -> int: ) ) return 1 + finally: + if previous_handler is not None: + signal.signal(signal.SIGTERM, previous_handler) sys.stdout.write(json.dumps(result)) return 0 +def _cancel_paired_worker(_signum: int, _frame: FrameType | None) -> None: + # Let private renderers reap their children before the parent escalates to SIGKILL. + raise JobCancelledError("Archive worker stopped") + + def _worker_convert(payload: dict[str, Any]) -> dict[str, Any]: from pullbox.utilities.executors.file_converter import _convert_sync diff --git a/tests/unit/test_pdf_metadata_writing.py b/tests/unit/test_pdf_metadata_writing.py new file mode 100644 index 00000000..8acd64c4 --- /dev/null +++ b/tests/unit/test_pdf_metadata_writing.py @@ -0,0 +1,325 @@ +"""PDF conversion must build the reconciled archive once and preserve its source.""" + +import asyncio +import io +import os +import shutil +import signal +import sys +import zipfile +from contextlib import suppress +from unittest.mock import patch + +import pytest +from PIL import Image + +from pullbox.core.exceptions import JobCancelledError +from pullbox.core.file_safety import FileSafetyError +from tests.unit.test_archive_metadata_writing import assert_pair, snapshots, write + +native_pdf = pytest.mark.skipif( + not (shutil.which("pdfinfo") and shutil.which("pdftoppm")), + reason="Native Poppler check; mandatory in production-image runtime validation", +) + + +def pdf_source(path, *, count=3): + pages = [Image.new("RGB", (72, 90), ("red", "green", "blue")[i % 3]) for i in range(count)] + try: + pages[0].save(path, format="PDF", save_all=True, append_images=pages[1:]) + finally: + for page in pages: + page.close() + return path + + +@native_pdf +def test_pdf_renders_one_consistent_cbz_in_reading_order(tmp_path): + source = pdf_source(tmp_path / "input.pdf") + original = source.read_bytes() + target = tmp_path / "result.cbz" + modes = [] + events = [] + init = zipfile.ZipFile.__init__ + + def tracked(self, file, mode="r", *args, **kwargs): + modes.append(mode) + init(self, file, mode, *args, **kwargs) + + with patch.object(zipfile.ZipFile, "__init__", tracked): + assert write(source, target, progress_callback=lambda *event: events.append(event)) + assert [mode for mode in modes if mode in {"w", "a", "x"}] == ["w"] + assert source.read_bytes() == original + assert_pair(target) + with zipfile.ZipFile(target) as archive: + pages = [name for name in archive.namelist() if name.endswith(".jpg")] + assert pages == ["page_0000.jpg", "page_0001.jpg", "page_0002.jpg"] + for name, channel in zip(pages, (0, 1, 2), strict=True): + with Image.open(io.BytesIO(archive.read(name))) as page: + assert page.size == (200, 250) + pixel = page.convert("RGB").getpixel((100, 100)) + assert pixel[channel] > max(pixel[(channel + 1) % 3], pixel[(channel + 2) % 3]) + assert [event for event in events if event[0] == "rendering"] == [ + ("rendering", value, 3, "pages") for value in range(4) + ] + assert set(tmp_path.iterdir()) == {source, target} + + +@pytest.mark.parametrize("stage_name", ["rendering", "transferring", "verifying", "publishing"]) +@native_pdf +def test_pdf_cancel_preserves_source_and_cleans_workspace(tmp_path, stage_name): + source = pdf_source(tmp_path / "input.pdf") + original = source.read_bytes() + + def cancel(stage, current, *_args): + if stage == stage_name and (current or stage == "publishing"): + raise JobCancelledError("stop") + + with pytest.raises(JobCancelledError): + write(source, tmp_path / "result.cbz", progress_callback=cancel) + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@native_pdf +@pytest.mark.parametrize("quality,extension", [("medium", "jpg"), ("high", "png")]) +async def test_real_worker_stages_pdf_pair_privately(tmp_path, quality, extension): + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + source = pdf_source(tmp_path / "input.pdf") + original = source.read_bytes() + async with stage_cbz_metadata_interruptible( + source, tmp_path, *snapshots(), max_uncompressed_bytes=1_000_000, pdf_quality=quality + ) as staged: + assert_pair(staged.path) + staged.check_unchanged() + assert staged.path.parent.parent == tmp_path + assert source.read_bytes() == original + with zipfile.ZipFile(staged.path) as archive: + assert f"page_0000.{extension}" in archive.namelist() + assert list(tmp_path.iterdir()) == [source] + + +def test_missing_pdf_renderer_has_a_fixed_error(monkeypatch): + from pullbox.core import metadata_pdf_source as pdf + + def unavailable(*_args, **_kwargs): + raise FileNotFoundError("private executable path") + + monkeypatch.setattr(pdf.subprocess, "Popen", unavailable) + with pytest.raises(FileSafetyError, match="renderer is unavailable") as caught: + pdf._run_bounded( + ["pdfinfo"], io.BytesIO(), limit=100, timeout=1, check_cancelled=lambda: None + ) + assert "private executable path" not in str(caught.value) + + +def test_pdf_size_limit_stops_before_publication(tmp_path): + from pullbox.services.archive_metadata_writing import write_cbz_metadata + + source = pdf_source(tmp_path / "input.pdf") + original = source.read_bytes() + with pytest.raises(FileSafetyError, match=r"size|limit"): + write_cbz_metadata(source, tmp_path / "result.cbz", *snapshots(), max_uncompressed_bytes=10) + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize( + "quality,extension,size", [("low", "jpg", (150, 188)), ("high", "png", (300, 375))] +) +@native_pdf +def test_pdf_quality_preserves_requested_resolution(tmp_path, quality, extension, size): + source = pdf_source(tmp_path / "input.pdf", count=1) + target = tmp_path / "result.cbz" + assert write(source, target, pdf_quality=quality) + assert_pair(target) + with ( + zipfile.ZipFile(target) as archive, + Image.open(io.BytesIO(archive.read(f"page_0000.{extension}"))) as image, + ): + assert image.size == size + + +@pytest.mark.parametrize( + "details", + [ + "Pages: 0\nEncrypted: no\n", + "Pages: 10001\nEncrypted: no\n", + "Pages: 1\nEncrypted: yes\nPage 1 size: 72 x 90 pts\n", + "Pages: 1\nEncrypted: no\nPage 1 size: nan x 90 pts\n", + "Pages: 1\nEncrypted: no\nPage 1 size: 100000 x 100000 pts\n", + "Pages: 1\nEncrypted: no\nPage 2 size: 72 x 90 pts\n", + "Pages: 1\nEncrypted: no\nPage 1 size: 72 x 90 pts\nPages: 10001\n", + "Pages: 1\nEncrypted: no\nPage 1 size: 72 x 90 pts\nEncrypted: yes\n", + ], +) +def test_pdf_rejects_unverifiable_inspection_before_rendering(tmp_path, monkeypatch, details): + from pullbox.core import metadata_pdf_source as pdf + + source = pdf_source(tmp_path / "input.pdf", count=1) + original = source.read_bytes() + calls = [] + + def inspect(command, output, **_kwargs): + calls.append(command[0]) + if command[0] != "pdfinfo": + pytest.fail("Unverifiable PDF must not reach the renderer") + return output.write(details.encode()) + + monkeypatch.setattr(pdf, "_run_bounded", inspect) + with pytest.raises(FileSafetyError): + write(source, tmp_path / "result.cbz") + assert calls == ["pdfinfo"] + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@pytest.mark.parametrize("boundary", ["budget", "timeout", "cancel", "exit", "closed_pipe"]) +def test_pdf_child_is_reaped_after_boundary_failure(monkeypatch, boundary): + from pullbox.core import metadata_pdf_source as pdf + + spawned = [] + popen = pdf.subprocess.Popen + + def spawn(*args, **kwargs): + child = popen(*args, **kwargs) + spawned.append(child) + return child + + monkeypatch.setattr(pdf.subprocess, "Popen", spawn) + program = { + "budget": "import os; os.write(1, b'x' * 1000000)", + "timeout": "import time; time.sleep(30)", + "cancel": "import time; time.sleep(30)", + "exit": "import sys; sys.stderr.write('private metadata'); sys.exit(2)", + "closed_pipe": "import os,time; os.close(1); time.sleep(30)", + }[boundary] + checks = 0 + + def cancel(): + nonlocal checks + checks += 1 + if boundary == "cancel" and checks > 2: + raise JobCancelledError("stop") + + output = io.BytesIO() + with pytest.raises(JobCancelledError if boundary == "cancel" else FileSafetyError) as caught: + pdf._run_bounded( + [sys.executable, "-c", program], output, limit=100, timeout=0.5, check_cancelled=cancel + ) + assert len(output.getvalue()) <= 100 + assert "private metadata" not in str(caught.value) + assert len(spawned) == 1 and spawned[0].poll() is not None + assert spawned[0].stdout.closed + + +@native_pdf +def test_corrupt_pdf_does_not_publish_or_leak_inspection_error(tmp_path): + source = tmp_path / "corrupt.pdf" + source.write_bytes(b"%PDF-1.5\nprivate invalid content") + with pytest.raises(FileSafetyError) as caught: + write(source, tmp_path / "result.cbz") + assert "private invalid content" not in str(caught.value) + assert list(tmp_path.iterdir()) == [source] + + +@native_pdf +async def test_worker_cancellation_while_pdf_renders_cleans_all_private_pages(tmp_path): + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + source = pdf_source(tmp_path / "input.pdf", count=100) + original = source.read_bytes() + rendering = False + + def progress(stage, *_args): + nonlocal rendering + rendering |= stage == "rendering" + + async def cancel(): + if rendering: + raise JobCancelledError("stop during rendering") + + with pytest.raises(JobCancelledError): + async with stage_cbz_metadata_interruptible( + source, + tmp_path, + *snapshots(), + max_uncompressed_bytes=10_000_000, + progress_callback=progress, + cancellation_check=cancel, + ): + pytest.fail("Cancelled rendering must not hand off a stage") + assert rendering + assert source.read_bytes() == original + assert list(tmp_path.iterdir()) == [source] + + +@native_pdf +def test_fractional_pdf_page_sizes_are_not_rejected_by_rounded_inspection(tmp_path): + source = tmp_path / "fractional.pdf" + with Image.new("RGB", (72, 90), "red") as page: + page.save(source, format="PDF", resolution=71.99999999) + target = tmp_path / "result.cbz" + assert write(source, target) + assert_pair(target) + with ( + zipfile.ZipFile(target) as archive, + Image.open(io.BytesIO(archive.read("page_0000.jpg"))) as page, + ): + assert page.size == (201, 251) + + +@native_pdf +@pytest.mark.skipif(os.name == "nt", reason="POSIX worker signal/child reaping contract") +async def test_pdf_worker_cancel_reaps_an_active_poppler_child(tmp_path, monkeypatch): + from pullbox.utilities.executors.archive_metadata_staging import ( + stage_cbz_metadata_interruptible, + ) + + source = pdf_source(tmp_path / "input.pdf", count=1) + original = source.read_bytes() + binary_dir = tmp_path / "bin" + binary_dir.mkdir() + marker = tmp_path / "renderer.pid" + fake = binary_dir / "pdftoppm" + fake.write_text( + f"#!{sys.executable}\nimport os, pathlib, signal, time\n" + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + f"pathlib.Path({str(marker)!r}).write_text(str(os.getpid()))\n" + "time.sleep(60)\n" + ) + fake.chmod(0o700) + monkeypatch.setenv("PATH", f"{binary_dir}{os.pathsep}{os.environ['PATH']}") + + async def convert(): + async with stage_cbz_metadata_interruptible( + source, tmp_path, *snapshots(), max_uncompressed_bytes=1_000_000 + ): + pytest.fail("stopped renderer must not produce an archive") + + task = asyncio.create_task(convert()) + pid = None + try: + async with asyncio.timeout(10): + while not marker.exists(): + await asyncio.sleep(0.01) + pid = int(marker.read_text()) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + assert source.read_bytes() == original + assert not list(tmp_path.glob(".pullbox-*")) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if pid is not None: + with suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) diff --git a/tests/unit/test_verify_container_security_runtime.py b/tests/unit/test_verify_container_security_runtime.py index 6eb1b791..0209cb06 100644 --- a/tests/unit/test_verify_container_security_runtime.py +++ b/tests/unit/test_verify_container_security_runtime.py @@ -29,9 +29,25 @@ def test_production_check_rejects_development_scanner_packages( def test_production_check_accepts_runtime_without_scanner(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(runtime, "find_spec", lambda name: None) monkeypatch.setattr(runtime.pyexpat, "version_info", runtime.MINIMUM_EXPAT_VERSION) + monkeypatch.setattr(runtime, "verify_paired_pdf_boundary", lambda: None, raising=False) runtime.main() +def test_production_check_requires_real_paired_pdf_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = [] + monkeypatch.setattr(runtime, "find_spec", lambda name: None) + monkeypatch.setattr(runtime.pyexpat, "version_info", runtime.MINIMUM_EXPAT_VERSION) + monkeypatch.setattr( + runtime, "verify_paired_pdf_boundary", lambda: calls.append("PDF"), raising=False + ) + runtime.main() + assert calls == ["PDF"], ( + "Production image must exercise its actual PDF renderer and paired worker" + ) + + def test_production_check_rejects_runtime_without_utf16_guard( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/utilities/test_archive_metadata_staging.py b/tests/utilities/test_archive_metadata_staging.py index e121df91..2aef3753 100644 --- a/tests/utilities/test_archive_metadata_staging.py +++ b/tests/utilities/test_archive_metadata_staging.py @@ -83,6 +83,40 @@ async def test_real_worker_yields_private_verified_pair_and_never_replaces_sourc assert list(source.parent.iterdir()) == [source] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX isolated worker group cleanup") +async def test_cancel_during_completed_worker_cleanup_cannot_return_prepared_output( + source, monkeypatch +): + entered = asyncio.Event() + release = asyncio.Event() + cleanup = archive_subprocess._terminate_worker_process + + async def delayed_cleanup(proc, communicate, **kwargs): + assert proc.returncode == 0 + entered.set() + await release.wait() + await cleanup(proc, communicate, **kwargs) + + async def prepare(): + async with stage(source): + return "unexpected prepared output" + + monkeypatch.setattr(archive_subprocess, "_terminate_worker_process", delayed_cleanup) + task = asyncio.create_task(prepare()) + try: + await asyncio.wait_for(entered.wait(), 10) + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert list(source.parent.iterdir()) == [source] + finally: + release.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_owner_can_publish_after_journal_boundary_without_repacking(source): target = source.parent / "library.cbz" async with stage(source) as prepared: @@ -300,7 +334,12 @@ async def communicate(self): async def launch(*_args, **_kwargs): return Process() + async def cleanup(proc, communicate, **_kwargs): + assert isinstance(proc, Process) + await communicate + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + monkeypatch.setattr(archive_subprocess, "_terminate_worker_process", cleanup) with pytest.raises(staging.ArchiveMetadataStagingError, match="worker_failed") as error: async with stage(source): pytest.fail("invalid worker result must not be accepted") diff --git a/tests/utilities/test_archive_subprocess.py b/tests/utilities/test_archive_subprocess.py index 39a2840c..f55be9d9 100644 --- a/tests/utilities/test_archive_subprocess.py +++ b/tests/utilities/test_archive_subprocess.py @@ -4,9 +4,13 @@ import asyncio import json +import os import shutil +import signal +import subprocess import sys import zipfile +from contextlib import suppress from pathlib import Path from typing import Any @@ -806,3 +810,45 @@ def flaky_copy(src: str, dst: str) -> None: assert calls == 2 assert target.read_text() == "comic" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX isolated worker group contract") +async def test_crashed_paired_worker_does_not_leave_a_running_helper(tmp_path, monkeypatch): + from pullbox.utilities.executors.archive_metadata_staging import ArchiveMetadataStagingError + + spawn = asyncio.create_subprocess_exec + marker = tmp_path / "child.pid" + script = ( + "import pathlib,subprocess,sys,time; " + "child=subprocess.Popen([sys.executable, '-c', " + "'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)'], " + "stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); " + f"pathlib.Path({str(marker)!r}).write_text(str(child.pid)); " + "time.sleep(0.1); sys.exit(1)" + ) + + async def launch(*_args, **kwargs): + return await spawn(sys.executable, "-c", script, **kwargs) + + monkeypatch.setattr(archive_subprocess.asyncio, "create_subprocess_exec", launch) + child_pid = None + try: + with pytest.raises(ArchiveMetadataStagingError): + await _run_archive_operation("paired_stage", {}) + child_pid = int(marker.read_text()) + for _attempt in range(50): + state = subprocess.run( + ["ps", "-o", "stat=", "-p", str(child_pid)], + capture_output=True, + text=True, + check=False, + timeout=1, + ).stdout.strip() + if not state or state.startswith("Z"): + break + await asyncio.sleep(0.01) + assert not state or state.startswith("Z"), "worker exited but its helper is still executing" + finally: + if child_pid is not None: + with suppress(ProcessLookupError): + os.kill(child_pid, signal.SIGKILL) From 54530faec2b3589bbb1ce2f89b76619d64c2ae7b Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 10:52:40 -0700 Subject: [PATCH 75/97] fix(library): keep deletion scoped to literal folder paths --- src/pullbox/api/v1/library.py | 8 +- .../services/library_delete_service.py | 20 +++- .../test_library_delete_selection.py | 95 +++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 tests/integration/metadata_identity/test_library_delete_selection.py diff --git a/src/pullbox/api/v1/library.py b/src/pullbox/api/v1/library.py index 71a1153b..dff7692e 100644 --- a/src/pullbox/api/v1/library.py +++ b/src/pullbox/api/v1/library.py @@ -257,7 +257,9 @@ async def _folder_has_tracked_descendants(session: DbSession, target: Path) -> b file_count = int( ( await session.execute( - select(func.count(LibraryFile.id)).where(LibraryFile.file_path.like(f"{prefix}/%")) + select(func.count(LibraryFile.id)).where( + LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True) + ) ) ).scalar_one() or 0 @@ -276,7 +278,9 @@ async def _folder_has_tracked_descendants(session: DbSession, target: Path) -> b series_count = int( ( await session.execute( - select(func.count(Series.id)).where(Series.path.like(f"{prefix}/%")) + select(func.count(Series.id)).where( + Series.path.startswith(f"{prefix}/", autoescape=True) + ) ) ).scalar_one() or 0 diff --git a/src/pullbox/services/library_delete_service.py b/src/pullbox/services/library_delete_service.py index fe27ab34..95eb85c7 100644 --- a/src/pullbox/services/library_delete_service.py +++ b/src/pullbox/services/library_delete_service.py @@ -203,7 +203,10 @@ async def build_delete_context( file_clause = ( LibraryFile.file_path == prefix if kind == "file" - else or_(LibraryFile.file_path == prefix, LibraryFile.file_path.like(f"{prefix}/%")) + else or_( + LibraryFile.file_path == prefix, + LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True), + ) ) tracked_file_count = int( (await session.execute(select(func.count(LibraryFile.id)).where(file_clause))).scalar_one() @@ -216,7 +219,10 @@ async def build_delete_context( ( await session.execute( select(func.count(Series.id)).where( - or_(Series.path == prefix, Series.path.like(f"{prefix}/%")) + or_( + Series.path == prefix, + Series.path.startswith(f"{prefix}/", autoescape=True), + ) ) ) ).scalar_one() @@ -297,7 +303,10 @@ async def delete_library_entry( file_clause = ( LibraryFile.file_path == prefix if kind == "file" - else or_(LibraryFile.file_path == prefix, LibraryFile.file_path.like(f"{prefix}/%")) + else or_( + LibraryFile.file_path == prefix, + LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True), + ) ) tracked_files = list( ( @@ -317,7 +326,10 @@ async def delete_library_entry( ( await session.execute( select(Series).where( - or_(Series.path == prefix, Series.path.like(f"{prefix}/%")) + or_( + Series.path == prefix, + Series.path.startswith(f"{prefix}/", autoescape=True), + ) ) ) ) diff --git a/tests/integration/metadata_identity/test_library_delete_selection.py b/tests/integration/metadata_identity/test_library_delete_selection.py new file mode 100644 index 00000000..cc26f5c1 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_delete_selection.py @@ -0,0 +1,95 @@ +"""Deletion must select literal paths and retain neighboring registrations.""" + +import pytest + +from pullbox.api.v1.library import _folder_has_tracked_descendants +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.issue import IssueStatus +from pullbox.models.library import LibraryFileStorageMode +from pullbox.services.library_delete_service import ( + LibraryDeleteContext, + build_delete_context, + delete_library_entry, +) +from tests.unit.test_library_delete_service import _seed_root, _seed_series_issue_file + + +@pytest.mark.parametrize("name,neighbor", [("Comics_", "ComicsX"), ("Comics%", "ComicsExtra")]) +@pytest.mark.parametrize("referenced", [False, True]) +@pytest.mark.parametrize("check_preview", [False, True]) +async def test_folder_delete_selects_only_literal_descendants( + identity_probe_db, tmp_path, name, neighbor, referenced, check_preview +): + _, factory, _ = identity_probe_db + target, sibling = tmp_path / name, tmp_path / neighbor + target.mkdir() + sibling.mkdir() + selected_path, untouched_path = target / "issue.cbz", sibling / "issue.cbz" + selected_path.write_bytes(b"selected") + untouched_path.write_bytes(b"neighbor") + async with factory.begin() as session: + root = await _seed_root(session, tmp_path) + selected = await _seed_series_issue_file( + session, root, selected_path, series_path=target / "nested" + ) + untouched = await _seed_series_issue_file( + session, + root, + untouched_path, + series_path=sibling / "nested", + storage_mode=( + LibraryFileStorageMode.REFERENCED if referenced else LibraryFileStorageMode.MANAGED + ), + ) + root_id = root.id + selected_ids = tuple(row.id for row in selected) + untouched_ids = tuple(row.id for row in untouched) + + async with factory.begin() as session: + context = LibraryDeleteContext(mode="folder", trash_enabled=False) + if check_preview: + context = await build_delete_context( + session, target=target, kind="folder", trash_enabled=False + ) + assert context.tracked_file_count == 1 + assert context.tracked_series_count == 1 + assert context.managed_file_count == 1 + assert context.referenced_file_count == 0 + root = await session.get(LibraryRoot, root_id) + outcome = await delete_library_entry( + session, target=target, root=root, kind="folder", delete_context=context + ) + assert outcome.managed_files_deleted == 1 + assert outcome.referenced_files_detached == 0 + + assert not target.exists() + assert untouched_path.read_bytes() == b"neighbor" + async with factory() as session: + assert (await session.get(Series, selected_ids[0])).path is None + assert (await session.get(Issue, selected_ids[1])).status == IssueStatus.WANTED + assert await session.get(LibraryFile, selected_ids[2]) is None + assert (await session.get(Series, untouched_ids[0])).path == str(sibling / "nested") + assert (await session.get(Issue, untouched_ids[1])).status == IssueStatus.OWNED + assert (await session.get(LibraryFile, untouched_ids[2])).file_path == str(untouched_path) + + +@pytest.mark.parametrize("name,neighbor", [("Comics_", "ComicsX"), ("Comics%", "ComicsExtra")]) +@pytest.mark.parametrize("with_file", [False, True]) +async def test_browser_tracking_does_not_include_wildcard_neighbors( + identity_probe_db, tmp_path, name, neighbor, with_file +): + _, factory, _ = identity_probe_db + target, sibling = tmp_path / name, tmp_path / neighbor + target.mkdir() + sibling.mkdir() + async with factory.begin() as session: + if with_file: + root = await _seed_root(session, tmp_path) + await _seed_series_issue_file(session, root, sibling / "nested" / "issue.cbz") + else: + session.add( + Series(title="Neighbor", sort_title="Neighbor", path=str(sibling / "nested")) + ) + async with factory() as session: + assert not await _folder_has_tracked_descendants(session, target) + assert await _folder_has_tracked_descendants(session, sibling) From 500f0f505ac983821c1a460ebdaa38b9fb210b92 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 11:23:40 -0700 Subject: [PATCH 76/97] feat(library): add durable removal staging and recovery --- ...0w1x2y3z456_add_library_removal_journal.py | 44 +++ src/pullbox/app.py | 6 + src/pullbox/models/__init__.py | 2 + src/pullbox/models/library_removal.py | 24 ++ .../services/archive_metadata_publication.py | 4 + .../services/library_mutation_coordination.py | 2 + src/pullbox/services/library_removal.py | 303 +++++++++++++++ .../metadata_identity/test_library_removal.py | 364 ++++++++++++++++++ .../test_library_removal_migration.py | 52 +++ tests/integration/test_migrations.py | 3 +- tests/unit/test_app_lifespan.py | 11 + 11 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/c0w1x2y3z456_add_library_removal_journal.py create mode 100644 src/pullbox/models/library_removal.py create mode 100644 src/pullbox/services/library_removal.py create mode 100644 tests/integration/metadata_identity/test_library_removal.py create mode 100644 tests/integration/metadata_identity/test_library_removal_migration.py diff --git a/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py b/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py new file mode 100644 index 00000000..2423066c --- /dev/null +++ b/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py @@ -0,0 +1,44 @@ +"""Retain authorization and private staging evidence for library removal.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "c0w1x2y3z456" +down_revision = "b9v0w1x2y345" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "library_removals", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("operation_id", sa.String(36), nullable=False, unique=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("state", sa.String(10), nullable=False), + sa.Column("plan_json", sa.Text(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.CheckConstraint( + "state IN ('intended','detached','complete','abandoned','review')", + name="ck_library_removal_state", + ), + sa.CheckConstraint( + "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_removal_plan_size" + ), + ) + op.create_index("ix_library_removals_active", "library_removals", ["active"]) + + +def downgrade() -> None: + table = sa.table("library_removals", sa.column("state")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned") + ): + raise RuntimeError("Resolve retained library removal evidence before downgrading.") + op.drop_table("library_removals") diff --git a/src/pullbox/app.py b/src/pullbox/app.py index 6a835deb..6424d83f 100644 --- a/src/pullbox/app.py +++ b/src/pullbox/app.py @@ -297,6 +297,12 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.warning("library_path_reconciliation_failed", exc_info=True) + # Restore uncommitted removals before conversion recovery or background writers. + from pullbox.services.library_removal import recover_library_removals + + async with get_session_factory()() as session: + await recover_library_removals(session) + # Retained conversion intent must be reconciled before background file writers. from pullbox.services.library_conversion_recovery import recover_library_conversions diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py index da3818f6..584cddbe 100644 --- a/src/pullbox/models/__init__.py +++ b/src/pullbox/models/__init__.py @@ -61,6 +61,7 @@ MatchConfidence, ) from pullbox.models.library_conversion import LibraryConversion +from pullbox.models.library_removal import LibraryRemoval from pullbox.models.matching_suggestion import MatchingSuggestion, SuggestionStatus from pullbox.models.metadata_baseline import ( IssueMetadataBaseline, @@ -200,6 +201,7 @@ "JobType", "LibraryConversion", "LibraryFile", + "LibraryRemoval", "LibraryRoot", "LibraryRootPolicy", "LibraryRootPolicySource", diff --git a/src/pullbox/models/library_removal.py b/src/pullbox/models/library_removal.py new file mode 100644 index 00000000..fd603dfa --- /dev/null +++ b/src/pullbox/models/library_removal.py @@ -0,0 +1,24 @@ +"""Retained removal authorization and private staging evidence.""" + +from sqlalchemy import Boolean, CheckConstraint, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from pullbox.models.base import Base, IdentityMixin, TimestampMixin + + +class LibraryRemoval(Base, IdentityMixin, TimestampMixin): + __tablename__ = "library_removals" + __table_args__ = ( + CheckConstraint( + "state IN ('intended','detached','complete','abandoned','review')", + name="ck_library_removal_state", + ), + CheckConstraint( + "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_removal_plan_size" + ), + ) + + operation_id: Mapped[str] = mapped_column(String(36), unique=True) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + state: Mapped[str] = mapped_column(String(10), default="intended") + plan_json: Mapped[str] = mapped_column(Text) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index 521be690..2ded610b 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -270,6 +270,7 @@ async def record_archive_publication( """ from pullbox.services.import_archive_publication import require_import_archive_owner from pullbox.services.library_conversion_recovery import require_no_library_conversion + from pullbox.services.library_removal import require_no_library_removal _clean_session(session) encoded = _encode(plan) @@ -277,6 +278,9 @@ async def record_archive_publication( try: async with metadata_write_scope(session): await lock_file_mutation_admission(session) + await require_no_library_removal( + session, plan.target.path, plan.stage_path, include_descendants=False + ) await require_no_library_conversion( session, plan.target.path, plan.stage_path, include_descendants=False ) diff --git a/src/pullbox/services/library_mutation_coordination.py b/src/pullbox/services/library_mutation_coordination.py index ca4bc44e..6950a74f 100644 --- a/src/pullbox/services/library_mutation_coordination.py +++ b/src/pullbox/services/library_mutation_coordination.py @@ -59,7 +59,9 @@ async def require_no_archive_publication( """ from pullbox.services.archive_metadata_publication import ArchivePublicationPlan from pullbox.services.library_conversion_recovery import require_no_library_conversion + from pullbox.services.library_removal import require_no_library_removal + await require_no_library_removal(session, *paths, include_descendants=include_descendants) await require_no_library_conversion(session, *paths, include_descendants=include_descendants) targets = {variant for path in paths for variant in (path.absolute(), path.resolve())} diff --git a/src/pullbox/services/library_removal.py b/src/pullbox/services/library_removal.py new file mode 100644 index 00000000..4ce9047c --- /dev/null +++ b/src/pullbox/services/library_removal.py @@ -0,0 +1,303 @@ +"""Durable staging and recovery boundary for library removal owners.""" + +import asyncio +import stat +from contextlib import suppress +from pathlib import Path +from typing import Literal +from uuid import UUID, uuid4 + +import structlog +from pydantic import BaseModel, ConfigDict +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.core.file_publication import rename_path_without_overwrite +from pullbox.core.library_file_ownership import require_mutable_library_target +from pullbox.models import LibraryRoot +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services.archive_metadata_binding import FileFingerprint +from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.library_conversion_files import directories, matches, sync_directory +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, +) + +logger = structlog.get_logger(__name__) + + +class RemovalPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[1] = 1 + operation_id: UUID + source: Path + stage: Path + root_id: int + root_path: Path + fingerprint: FileFingerprint + directories: tuple[tuple[Path, int, int, int], ...] + + +def prepare_removal(source: Path, *, root_id: int, root_path: Path) -> RemovalPlan: + """Create an empty private sibling; caller owns recording or discarding intent.""" + source, root_path = source.absolute(), root_path.resolve() + before = _fingerprint(source) + if ( + before is None + or source.resolve() != source + or source == root_path + or not source.is_relative_to(root_path) + or not (stat.S_ISREG(before[5]) or stat.S_ISDIR(before[5])) + ): + raise ValidationError("Library removal requires a regular file or folder inside its root.") + operation = uuid4() + private = source.parent / f".pullbox-removal-{operation.hex}" + private.mkdir(mode=0o700) + created = private.lstat() + try: + plan = RemovalPlan( + operation_id=operation, + source=source, + stage=private / "payload", + root_id=root_id, + root_path=root_path, + fingerprint=before, + directories=directories(source, private / "payload"), + ) + _check_locations(plan) + return decode_removal(plan.model_dump_json()) + except BaseException: + with suppress(OSError): + current = private.lstat() + if (current.st_dev, current.st_ino) == (created.st_dev, created.st_ino): + private.rmdir() + raise + + +async def recover_library_removals(session: AsyncSession) -> int: + """Bounded startup pass restoring uncommitted intent, never deleting artifacts.""" + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Library removal recovery requires a clean session.") + last_id = restored = 0 + while True: + rows = ( + await session.execute( + select(LibraryRemoval.id, LibraryRemoval.operation_id) + .where( + LibraryRemoval.id > last_id, + LibraryRemoval.active.is_(True), + LibraryRemoval.state == "intended", + ) + .order_by(LibraryRemoval.id) + .limit(8) + ) + ).all() + await session.commit() + if not rows: + return restored + for row_id, operation in rows: + last_id = row_id + try: + state = await recover_uncommitted_removal(session, UUID(operation)) + restored += state == "abandoned" + logger.info("library_removal_recovered", operation_id=operation, state=state) + except Exception: + await session.rollback() + logger.warning( + "library_removal_recovery_deferred", operation_id=operation, exc_info=True + ) + + +def decode_removal(encoded: str, *, operation_id: str | None = None) -> RemovalPlan: + if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536: + raise ValidationError("Library removal evidence exceeds its limit.") + plan = RemovalPlan.model_validate_json(encoded) + expected_dirs = set(plan.source.parents) | set(plan.stage.parents) + if ( + (operation_id is not None and str(plan.operation_id) != operation_id) + or any( + not path.is_absolute() or ".." in path.parts + for path in (plan.source, plan.stage, plan.root_path) + ) + or plan.stage + != plan.source.parent / f".pullbox-removal-{plan.operation_id.hex}" / "payload" + or plan.source == plan.root_path + or not plan.source.is_relative_to(plan.root_path) + or len(plan.directories) != len(expected_dirs) + or {entry[0] for entry in plan.directories} != expected_dirs + or not (stat.S_ISREG(plan.fingerprint[5]) or stat.S_ISDIR(plan.fingerprint[5])) + or any(not stat.S_ISDIR(mode) for _, _, _, mode in plan.directories) + ): + raise ValidationError("Library removal evidence is invalid.") + return plan + + +def _check_locations(plan: RemovalPlan) -> None: + # Removal and conversion retain the same immutable parent-directory proof. + for path, device, inode, mode in plan.directories: + info = path.lstat() + if (info.st_dev, info.st_ino, info.st_mode) != ( + device, + inode, + mode, + ) or path.resolve() != path: + raise ValidationError("Library removal locations changed; review before retrying.") + if stat.S_IMODE(plan.stage.parent.stat().st_mode) != 0o700: + raise ValidationError("Library removal staging must remain private.") + + +async def _check_authority(session: AsyncSession, plan: RemovalPlan) -> None: + root = await session.get( + LibraryRoot, plan.root_id, populate_existing=True, with_for_update=True + ) + if ( + root is None + or not root.enabled + or not root.allow_managed_writes + or Path(root.path).resolve() != plan.root_path + ): + raise ValidationError("Library removal requires its unchanged managed root.") + await require_mutable_library_target( + session, + plan.source, + include_descendants=stat.S_ISDIR(plan.fingerprint[5]), + operation="removed", + ) + _check_locations(plan) + + +async def record_removal(session: AsyncSession, plan: RemovalPlan) -> None: + """Reserve a caller-authorized target; caller commits before staging.""" + from pullbox.services.library_mutation_coordination import require_no_archive_publication + + decode_removal(plan.model_dump_json()) + await lock_file_mutation_admission(session) + await require_no_archive_publication( + session, plan.source, plan.stage.parent, include_descendants=True + ) + await _check_authority(session, plan) + if _fingerprint(plan.source) != plan.fingerprint or _fingerprint(plan.stage) is not None: + raise ValidationError("Library removal source or staging changed.") + session.add( + LibraryRemoval(operation_id=str(plan.operation_id), plan_json=plan.model_dump_json()) + ) + await session.flush() + session.info[f"removal_intent:{plan.operation_id}"] = session.sync_session.get_transaction() + + +async def _locked_removal(session: AsyncSession, operation_id: UUID) -> LibraryRemoval | None: + await lock_file_mutation_admission(session) + row: LibraryRemoval | None = await session.scalar( + select(LibraryRemoval) + .where(LibraryRemoval.operation_id == str(operation_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + return row + + +async def stage_removal(session: AsyncSession, operation_id: UUID) -> None: + """Short rename; owner detaches matching DB records in this same transaction.""" + if ( + session.in_transaction() + and session.info.get(f"removal_intent:{operation_id}") + is session.sync_session.get_transaction() + ): + raise ValidationError("Library removal intent must commit before staging.") + row = await _locked_removal(session, operation_id) + if row is None or not row.active or row.state != "intended": + raise ValidationError("Library removal has already been recovered; retry the operation.") + plan = decode_removal(row.plan_json, operation_id=row.operation_id) + await _check_authority(session, plan) + + def stage() -> None: + _check_locations(plan) + if _fingerprint(plan.source) != plan.fingerprint: + raise ValidationError("Library removal source changed.") + rename_path_without_overwrite(plan.source, plan.stage) + sync_directory(plan.source.parent) + sync_directory(plan.stage.parent) + + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(stage))) + row.state = "detached" + await session.flush() + + +async def recover_uncommitted_removal(session: AsyncSession, operation_id: UUID) -> str: + """Own a clean session; restore an uncommitted removal, never resume deletion.""" + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Library removal recovery requires a clean session.") + row = await _locked_removal(session, operation_id) + if row is None or not row.active or row.state != "intended": + state = row.state if row else "missing" + await session.rollback() + return state + plan = decode_removal(row.plan_json, operation_id=row.operation_id) + + def restore() -> bool: + _check_locations(plan) + source, stage = _fingerprint(plan.source), _fingerprint(plan.stage) + if matches(source, plan.fingerprint) and stage is None: + return True + if source is None and matches(stage, plan.fingerprint): + rename_path_without_overwrite(plan.stage, plan.source) + sync_directory(plan.source.parent) + sync_directory(plan.stage.parent) + return True + return False + + restored = await finish_short_mutation(asyncio.create_task(asyncio.to_thread(restore))) + row.state, row.active = ("abandoned", False) if restored else ("review", True) + state = row.state + await finish_short_mutation(asyncio.create_task(session.commit())) + return state + + +async def require_no_library_removal( + session: AsyncSession, *paths: Path, include_descendants: bool +) -> None: + """Check durable path scopes while the caller holds mutation admission.""" + targets = {variant for path in paths for variant in (path.absolute(), path.resolve())} + last_id = 0 + while True: + rows = ( + await session.execute( + select( + LibraryRemoval.id, + LibraryRemoval.operation_id, + case( + (func.length(LibraryRemoval.plan_json) <= 65536, LibraryRemoval.plan_json), + else_=None, + ), + ) + .where(LibraryRemoval.active.is_(True), LibraryRemoval.id > last_id) + .order_by(LibraryRemoval.id) + .limit(8) + ) + ).all() + if not rows: + return + for row_id, operation_id, encoded in rows: + last_id = row_id + try: + plan = decode_removal(encoded, operation_id=operation_id) + except (ValueError, TypeError): + raise ValidationError( + "A pending library removal needs recovery before files can change." + ) from None + for reserved, descendants in ( + (plan.source, stat.S_ISDIR(plan.fingerprint[5])), + (plan.stage.parent, True), + ): + if any( + target == reserved + or (descendants and target.is_relative_to(reserved)) + or (include_descendants and reserved.is_relative_to(target)) + for target in targets + ): + raise ValidationError( + "A library removal is pending for this file or folder. " + "Recover it before retrying." + ) diff --git a/tests/integration/metadata_identity/test_library_removal.py b/tests/integration/metadata_identity/test_library_removal.py new file mode 100644 index 00000000..1bb60235 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_removal.py @@ -0,0 +1,364 @@ +"""Real file/database boundary for recoverable deletion staging.""" + +import asyncio +import threading +from contextlib import asynccontextmanager +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update + +from pullbox.core.exceptions import ValidationError +from pullbox.models import LibraryFile, LibraryRoot +from pullbox.models.library import LibraryFileStorageMode +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services import library_removal as removals +from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.library_conversion_files import directories +from pullbox.services.library_removal import ( + RemovalPlan, + prepare_removal, + record_removal, + recover_library_removals, + recover_uncommitted_removal, + require_no_library_removal, + stage_removal, +) +from pullbox.services.library_rename_service import rename_library_entry +from tests.integration.metadata_identity import test_archive_metadata_publication as archive_cases +from tests.unit.test_library_delete_service import _seed_root, _seed_series_issue_file + + +@asynccontextmanager +async def prepared(factory, tmp_path, *, folder=False): + root_path = tmp_path.resolve() / "library" + root_path.mkdir() + folder_path = root_path / "series" + folder_path.mkdir() + file = folder_path / "issue.cbz" + file.write_bytes(b"original comic") + source = folder_path if folder else file + async with factory.begin() as session: + root = await _seed_root(session, root_path) + _, _, linked = await _seed_series_issue_file(session, root, file) + root_id, file_id = root.id, linked.id + plan = prepare_removal(source, root_id=root_id, root_path=root_path) + yield plan, file_id + + +async def record(factory, plan): + async with factory.begin() as session: + await record_removal(session, plan) + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_removal_records_intent_before_touching_source(identity_probe_db, tmp_path, folder): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, folder=folder) as (plan, _): + await record(factory, plan) + assert plan.source.exists() and not plan.stage.exists() + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert row is not None, "Removal must retain intent before changing disk" + assert row.state == "intended" and row.active + assert row.plan_json == plan.model_dump_json() + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_uncommitted_staging_restores_source_on_recovery(identity_probe_db, tmp_path, folder): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, folder=folder) as (plan, file_id): + await record(factory, plan) + async with factory() as session: + await stage_removal(session, plan.operation_id) + assert not plan.source.exists() and plan.stage.exists() + await session.delete(await session.get(LibraryFile, file_id)) + await session.flush() + await session.rollback() + async with factory() as session: + assert await recover_uncommitted_removal(session, plan.operation_id) == "abandoned" + assert plan.source.exists() and not plan.stage.exists() + async with factory() as session: + assert await session.get(LibraryFile, file_id) is not None + assert not (await session.scalar(select(LibraryRemoval))).active + + +async def test_staged_removal_commits_with_registration_detachment(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, file_id): + await record(factory, plan) + async with factory.begin() as session: + await stage_removal(session, plan.operation_id) + await session.delete(await session.get(LibraryFile, file_id)) + async with factory() as session: + assert await session.get(LibraryFile, file_id) is None + row = await session.scalar(select(LibraryRemoval)) + assert row is not None and row.state == "detached" + assert await recover_uncommitted_removal(session, plan.operation_id) == "detached" + assert not plan.source.exists() and plan.stage.read_bytes() == b"original comic" + + +@pytest.mark.parametrize( + "change", ["reference", "disabled_root", "changed_source", "same_transaction"] +) +async def test_staging_revalidates_authority_and_committed_intent( + identity_probe_db, tmp_path, change +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, file_id): + if change != "same_transaction": + await record(factory, plan) + async with factory.begin() as session: + if change == "reference": + await session.execute( + update(LibraryFile) + .where(LibraryFile.id == file_id) + .values(storage_mode=LibraryFileStorageMode.REFERENCED) + ) + elif change == "disabled_root": + await session.execute(update(LibraryRoot).values(enabled=False)) + if change == "changed_source": + plan.source.write_bytes(b"replacement") + async with factory() as session: + if change == "same_transaction": + await record_removal(session, plan) + with pytest.raises(ValidationError): + await stage_removal(session, plan.operation_id) + assert plan.source.exists() and not plan.stage.exists() + + +async def test_recovery_never_overwrites_replacement(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + async with factory() as session: + await stage_removal(session, plan.operation_id) + await session.rollback() + plan.source.write_bytes(b"replacement") + async with factory() as session: + assert await recover_uncommitted_removal(session, plan.operation_id) == "review" + assert (await session.scalar(select(LibraryRemoval))).active + assert plan.source.read_bytes() == b"replacement" + assert plan.stage.read_bytes() == b"original comic" + + +@pytest.mark.parametrize("relation", ["source", "ancestor", "descendant", "stage"]) +async def test_removal_reservations_cover_whole_path_scope(identity_probe_db, tmp_path, relation): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path, folder=True) as (plan, _): + await record(factory, plan) + path = { + "source": plan.source, + "ancestor": plan.source.parent, + "descendant": plan.source / "new.cbz", + "stage": plan.stage, + }[relation] + async with factory() as session: + with pytest.raises(ValidationError, match="removal"): + await require_no_library_removal( + session, path, include_descendants=relation == "ancestor" + ) + await require_no_library_removal( + session, plan.source.with_name("other"), include_descendants=True + ) + + +async def test_removal_blocks_new_archive_publication(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with archive_cases.prepared(factory, tmp_path) as (path, _, archive_plan): + operation = uuid4() + stage_parent = path.parent / f".pullbox-removal-{operation.hex}" + stage_parent.mkdir(mode=0o700) + async with factory() as session: + root = await session.get(LibraryRoot, archive_plan.target.binding.library_root_id) + plan = RemovalPlan( + operation_id=operation, + source=path, + stage=stage_parent / "payload", + root_id=root.id, + root_path=root.path, + fingerprint=_fingerprint(path), + directories=directories(path, stage_parent / "payload"), + ) + await record(factory, plan) + with pytest.raises(ValidationError, match="removal"): + await archive_cases.record(factory, archive_plan) + assert path.exists() + + +@pytest.mark.parametrize("change", ["operation", "oversized", "invalid", "stage_parent"]) +async def test_changed_removal_evidence_fails_closed(identity_probe_db, tmp_path, change): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + encoded = { + "operation": plan.model_copy(update={"operation_id": uuid4()}).model_dump_json(), + "oversized": '"' + "x" * 65534 + '"', + "invalid": "{}", + "stage_parent": plan.model_copy( + update={"stage": tmp_path / "outside"} + ).model_dump_json(), + }[change] + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(plan_json=encoded)) + async with factory() as session: + with pytest.raises((ValueError, ValidationError)): + await stage_removal(session, plan.operation_id) + async with factory() as session: + with pytest.raises((ValueError, ValidationError)): + await require_no_library_removal(session, plan.source, include_descendants=False) + assert plan.source.exists() + + +async def test_removal_row_must_match_its_plan_identity(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + changed = uuid4() + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(operation_id=str(changed))) + async with factory() as session: + with pytest.raises(ValidationError): + await stage_removal(session, changed) + async with factory() as session: + with pytest.raises(ValidationError): + await require_no_library_removal(session, plan.source, include_descendants=False) + assert plan.source.exists() + + +async def test_preparation_only_creates_private_sibling_staging(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root_path = tmp_path.resolve() / "library" + root_path.mkdir() + source = root_path / "issue.cbz" + source.write_bytes(b"original") + async with factory.begin() as session: + root = await _seed_root(session, root_path) + root_id = root.id + plan = prepare_removal(source, root_id=root_id, root_path=root_path) + assert isinstance(plan, RemovalPlan) + assert plan.stage.parent.parent == source.parent + assert plan.stage.parent.stat().st_mode & 0o777 == 0o700 + assert not plan.stage.exists() + assert source.read_bytes() == b"original" + await record(factory, plan) + async with factory.begin() as session: + await stage_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"original" + + +async def test_startup_restores_only_uncommitted_removals(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + async with factory() as session: + await stage_removal(session, plan.operation_id) + await session.rollback() + async with factory() as session: + assert await recover_library_removals(session) == 1 + assert plan.source.read_bytes() == b"original comic" + async with factory() as session: + assert await recover_library_removals(session) == 0 + + +async def test_removal_prevents_rename_even_after_file_registration_deleted( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, file_id): + await record(factory, plan) + async with factory.begin() as session: + await session.delete(await session.get(LibraryFile, file_id)) + async with factory() as session: + with pytest.raises(ValidationError, match="removal"): + await rename_library_entry( + session, + source=plan.source, + target=plan.source.with_name("new.cbz"), + kind="file", + ) + assert plan.source.exists() + + +async def test_cancelled_staging_is_joined_before_rollback( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + entered, release = threading.Event(), threading.Event() + real = removals.rename_path_without_overwrite + + def pause_after_rename(source, destination): + real(source, destination) + entered.set() + assert release.wait(10) + + monkeypatch.setattr(removals, "rename_path_without_overwrite", pause_after_rename) + async with factory() as session: + task = asyncio.create_task(stage_removal(session, plan.operation_id)) + assert await asyncio.to_thread(entered.wait, 10) + task.cancel() + await asyncio.sleep(0.05) + try: + assert not task.done(), "Cancelled staging must not outlive its transaction" + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 10) + await session.rollback() + async with factory() as session: + assert await recover_uncommitted_removal(session, plan.operation_id) == "abandoned" + assert plan.source.read_bytes() == b"original comic" + + +async def test_lost_commit_response_does_not_restore_committed_deletion( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, file_id): + await record(factory, plan) + async with factory() as session: + await stage_removal(session, plan.operation_id) + await session.delete(await session.get(LibraryFile, file_id)) + await session.commit() + # Model the caller treating a lost acknowledgement as failure. + await session.rollback() + async with factory() as session: + assert await recover_uncommitted_removal(session, plan.operation_id) == "detached" + assert await session.get(LibraryFile, file_id) is None + assert not plan.source.exists() and plan.stage.exists() + + +async def test_removal_admission_serializes_with_rename(identity_probe_db, tmp_path, monkeypatch): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + entered, release = asyncio.Event(), asyncio.Event() + real = removals._check_authority + + async def pause(*args): + entered.set() + await asyncio.wait_for(release.wait(), 10) + await real(*args) + + monkeypatch.setattr(removals, "_check_authority", pause) + pending = asyncio.create_task(record(factory, plan)) + await asyncio.wait_for(entered.wait(), 10) + async with factory() as session: + rename = asyncio.create_task( + rename_library_entry( + session, + source=plan.source, + target=plan.source.with_name("new.cbz"), + kind="file", + ) + ) + try: + await asyncio.sleep(0.05) + assert not rename.done() + finally: + release.set() + await asyncio.wait_for(pending, 10) + result = await asyncio.gather(rename, return_exceptions=True) + assert isinstance(result[0], ValidationError) + assert plan.source.exists() diff --git a/tests/integration/metadata_identity/test_library_removal_migration.py b/tests/integration/metadata_identity/test_library_removal_migration.py new file mode 100644 index 00000000..3d494084 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_removal_migration.py @@ -0,0 +1,52 @@ +"""Removal journal migration must retain unresolved authorization and receipts.""" + +import importlib.util +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, select, update + +from pullbox.models.library_removal import LibraryRemoval +from tests.integration.metadata_identity.test_library_removal import prepared, record + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/c0w1x2y3z456_add_library_removal_journal.py" + ) + spec = importlib.util.spec_from_file_location("library_removal_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_removal_migration_roundtrip(identity_probe_db): + engine, _, _ = identity_probe_db + table = LibraryRemoval.__table__ + async with engine.begin() as connection: + await connection.run_sync(table.drop) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + assert await connection.run_sync(lambda conn: inspect(conn).has_table(table.name)) + await connection.run_sync(lambda conn: revision(conn).downgrade()) + assert not await connection.run_sync(lambda conn: inspect(conn).has_table(table.name)) + await connection.run_sync(lambda conn: revision(conn).upgrade()) + + +@pytest.mark.parametrize("state", ["intended", "detached", "complete", "review"]) +async def test_removal_downgrade_refuses_retained_evidence(identity_probe_db, tmp_path, state): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + async with factory.begin() as session: + await session.execute( + update(LibraryRemoval).values(state=state, active=state != "complete") + ) + with pytest.raises(RuntimeError, match="removal evidence"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + async with factory() as session: + assert await session.scalar(select(LibraryRemoval.plan_json)) == plan.model_dump_json() diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 95945f9f..4b41fd09 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["b9v0w1x2y345"] + assert script.get_heads() == ["c0w1x2y3z456"] + assert script.get_revision("c0w1x2y3z456").down_revision == "b9v0w1x2y345" assert script.get_revision("b9v0w1x2y345").down_revision == "a8u9v0w1x234" assert script.get_revision("a8u9v0w1x234").down_revision == "z7t8u9v0w123" assert script.get_revision("z7t8u9v0w123").down_revision == "y6s7t8u9v012" diff --git a/tests/unit/test_app_lifespan.py b/tests/unit/test_app_lifespan.py index fb1deafe..1c3fdcf5 100644 --- a/tests/unit/test_app_lifespan.py +++ b/tests/unit/test_app_lifespan.py @@ -202,6 +202,10 @@ async def recover_conversions(_session: object) -> int: _LIFESPAN_EVENTS.append("conversion_recovery_completed") return 1 + async def recover_removals(_session: object) -> int: + _LIFESPAN_EVENTS.append("removal_recovery_completed") + return 1 + def session_factory() -> _FakeSession: return _FakeSession() @@ -241,6 +245,10 @@ async def start_airdcpp_registry(_factory: object, *, enabled: bool) -> object | monkeypatch.setattr(app, "_run_debug_logging_expiry_enforcer", neverending_debug_enforcer) monkeypatch.setattr(app, "get_event_bus", lambda: event_bus) monkeypatch.setattr(app, "get_session_factory", lambda: session_factory) + monkeypatch.setattr( + "pullbox.services.library_removal.recover_library_removals", + recover_removals, + ) monkeypatch.setattr( "pullbox.services.library_conversion_recovery.recover_library_conversions", recover_conversions, @@ -403,6 +411,9 @@ async def test_import_recovery_completes_before_scheduler_can_start( async with patched_lifespan.app.lifespan(FastAPI()): assert "import_recovery_completed" in _LIFESPAN_EVENTS assert "scheduler_started" in _LIFESPAN_EVENTS + assert _LIFESPAN_EVENTS.index("removal_recovery_completed") < _LIFESPAN_EVENTS.index( + "conversion_recovery_completed" + ) assert _LIFESPAN_EVENTS.index("conversion_recovery_completed") < _LIFESPAN_EVENTS.index( "import_recovery_completed" ) From 5a0572cb5d3cf5213176570ce8e5ac3c4977c004 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 12:26:43 -0700 Subject: [PATCH 77/97] feat(library): finish committed removals with recoverable cleanup --- ...x2y3z4a567_add_removal_cleanup_evidence.py | 36 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 12 + docs/development/DATABASE_STANDARDS.md | 24 ++ src/pullbox/models/library_removal.py | 5 + src/pullbox/services/library_removal.py | 80 +++- .../services/library_removal_cleanup.py | 269 +++++++++++++ src/pullbox/services/library_removal_files.py | 239 ++++++++++++ .../test_library_removal_cleanup.py | 362 ++++++++++++++++++ .../test_library_removal_cleanup_migration.py | 65 ++++ tests/integration/test_migrations.py | 3 +- 10 files changed, 1090 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py create mode 100644 src/pullbox/services/library_removal_cleanup.py create mode 100644 src/pullbox/services/library_removal_files.py create mode 100644 tests/integration/metadata_identity/test_library_removal_cleanup.py create mode 100644 tests/integration/metadata_identity/test_library_removal_cleanup_migration.py diff --git a/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py b/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py new file mode 100644 index 00000000..2fc0ecc2 --- /dev/null +++ b/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py @@ -0,0 +1,36 @@ +"""Retain bounded backup and cleanup evidence after removal detachment.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "d1x2y3z4a567" +down_revision = "c0w1x2y3z456" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "library_removals", + sa.Column( + "cleanup_json", + sa.Text(), + sa.CheckConstraint( + "cleanup_json IS NULL OR length(cleanup_json) BETWEEN 2 AND 4096", + name="ck_library_removal_cleanup_size", + ), + nullable=True, + ), + ) + + +def downgrade() -> None: + table = sa.table("library_removals", sa.column("cleanup_json")) + if op.get_bind().scalar( + sa.select(sa.func.count()).select_from(table).where(table.c.cleanup_json.is_not(None)) + ): + raise RuntimeError("Resolve retained removal cleanup evidence before downgrading.") + with op.batch_alter_table("library_removals") as batch: + batch.drop_constraint("ck_library_removal_cleanup_size", type_="check") + batch.drop_column("cleanup_json") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 637285e2..62733739 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -629,6 +629,18 @@ the paired renderer is not activated by this lifecycle change. Bulk conversion, deletion and other writers still need to join coordination before paired background metadata writing can be enabled. +`library_removal.py` adds durable reservation and same-filesystem private staging +for deletion owners. Startup restores uncommitted staging before conversion +recovery. `library_removal_cleanup.py` finishes explicitly authorized, committed +detachment without holding the database writer across copying, hashing or recursive +cleanup. Retained receipts and an OS file lock fence overlapping cleanup workers; +trash publication never overwrites an existing destination. Same-filesystem trash +renames rather than copying large collections. Cancellation leaves owned partial +copies/deletions retryable, and cleanup never follows the old public source path. +The browser, series and bulk deletion commands still need to adopt this lifecycle; +the primitives alone do not enable paired production writers or change public +deletion semantics. Trash-retention coordination and non-POSIX locking remain open. + The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and series/issue/arc adapter operations; GCD execution remains unimplemented, and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 5b98c739..98c8b21e 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -601,6 +601,30 @@ never permits deleting a registered output. Downgrade refuses retained completed or unresolved conversion evidence. Bulk utilities and other mutation entry points still require coordinated lifecycles before paired background writers activate. +### Library Removal Journal + +`library_removals` retains bounded immutable source, root, private-stage and +optional trash evidence independently of LibraryFile/series lifetimes. Admission +reserves overlapping paths against archive publication, conversion and rename. +The owner commits intent before a short exclusive staging rename, then commits +the journal's detached state together with its selected database removals. +Startup restores only uncommitted staging; it never resumes destructive cleanup. + +An explicit delete/trash disposition is required for cleanup. Historical plans +default to retain-only. `cleanup_json` retains bounded copy/publication/deletion +receipts; downgrade refuses to discard them. Cleanup owns clean-session +transactions and holds a non-expiring OS file lock across its lifecycle, not a +database write transaction across slow work. Same-filesystem trash uses exclusive +rename; cross-filesystem trash copies privately, verifies content, and publishes +without replacing an existing destination before removing its private source. +Cancellation joins workers and leaves retryable proof. Unknown replacements are +preserved. The old public source path is never used for cleanup. + +These are owner-integration primitives, not yet the public delete implementation. +Series/browser/bulk deletion and trash retention must adopt the complete lifecycle +before paired background writing is enabled. POSIX file locking is implemented; +other platforms currently fail closed at private cleanup admission. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/models/library_removal.py b/src/pullbox/models/library_removal.py index fd603dfa..7b08c930 100644 --- a/src/pullbox/models/library_removal.py +++ b/src/pullbox/models/library_removal.py @@ -16,9 +16,14 @@ class LibraryRemoval(Base, IdentityMixin, TimestampMixin): CheckConstraint( "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_removal_plan_size" ), + CheckConstraint( + "cleanup_json IS NULL OR length(cleanup_json) BETWEEN 2 AND 4096", + name="ck_library_removal_cleanup_size", + ), ) operation_id: Mapped[str] = mapped_column(String(36), unique=True) active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) state: Mapped[str] = mapped_column(String(10), default="intended") plan_json: Mapped[str] = mapped_column(Text) + cleanup_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/src/pullbox/services/library_removal.py b/src/pullbox/services/library_removal.py index 4ce9047c..f42a124e 100644 --- a/src/pullbox/services/library_removal.py +++ b/src/pullbox/services/library_removal.py @@ -38,9 +38,19 @@ class RemovalPlan(BaseModel): root_path: Path fingerprint: FileFingerprint directories: tuple[tuple[Path, int, int, int], ...] + disposition: Literal["retain", "delete", "trash"] = "retain" + trash_path: Path | None = None + trash_stage: Path | None = None -def prepare_removal(source: Path, *, root_id: int, root_path: Path) -> RemovalPlan: +def prepare_removal( + source: Path, + *, + root_id: int, + root_path: Path, + disposition: Literal["retain", "delete", "trash"] = "retain", + trash_path: Path | None = None, +) -> RemovalPlan: """Create an empty private sibling; caller owns recording or discarding intent.""" source, root_path = source.absolute(), root_path.resolve() before = _fingerprint(source) @@ -52,11 +62,28 @@ def prepare_removal(source: Path, *, root_id: int, root_path: Path) -> RemovalPl or not (stat.S_ISREG(before[5]) or stat.S_ISDIR(before[5])) ): raise ValidationError("Library removal requires a regular file or folder inside its root.") + if (disposition == "trash") != (trash_path is not None): + raise ValidationError("Trash removal requires an explicit destination.") + if trash_path is not None and ( + not trash_path.is_absolute() + or trash_path.resolve() != trash_path + or trash_path.is_relative_to(source) + or source.is_relative_to(trash_path) + ): + raise ValidationError("Trash must be a separate, resolved destination.") operation = uuid4() private = source.parent / f".pullbox-removal-{operation.hex}" private.mkdir(mode=0o700) created = private.lstat() + trash_private: Path | None = None + trash_created = None try: + if trash_path is not None: + trash_path.parent.mkdir(parents=True, exist_ok=True) + trash_private = trash_path.parent / f".pullbox-removal-trash-{operation.hex}" + trash_private.mkdir(mode=0o700) + trash_created = trash_private.lstat() + trash_stage = trash_private / "payload" if trash_private else None plan = RemovalPlan( operation_id=operation, source=source, @@ -64,11 +91,21 @@ def prepare_removal(source: Path, *, root_id: int, root_path: Path) -> RemovalPl root_id=root_id, root_path=root_path, fingerprint=before, - directories=directories(source, private / "payload"), + directories=directories( + source, private / "payload", *((trash_path, trash_stage) if trash_stage else ()) + ), + disposition=disposition, + trash_path=trash_path, + trash_stage=trash_stage, ) _check_locations(plan) return decode_removal(plan.model_dump_json()) except BaseException: + if trash_private is not None and trash_created is not None: + with suppress(OSError): + current = trash_private.lstat() + if (current.st_dev, current.st_ino) == (trash_created.st_dev, trash_created.st_ino): + trash_private.rmdir() with suppress(OSError): current = private.lstat() if (current.st_dev, current.st_ino) == (created.st_dev, created.st_ino): @@ -115,11 +152,28 @@ def decode_removal(encoded: str, *, operation_id: str | None = None) -> RemovalP raise ValidationError("Library removal evidence exceeds its limit.") plan = RemovalPlan.model_validate_json(encoded) expected_dirs = set(plan.source.parents) | set(plan.stage.parents) + if plan.trash_path is not None and plan.trash_stage is not None: + expected_dirs |= set(plan.trash_path.parents) | set(plan.trash_stage.parents) + if ( + plan.trash_path.resolve() != plan.trash_path + or plan.trash_path.is_relative_to(plan.source) + or plan.source.is_relative_to(plan.trash_path) + or plan.trash_stage + != plan.trash_path.parent + / f".pullbox-removal-trash-{plan.operation_id.hex}" + / "payload" + ): + raise ValidationError("Library removal trash evidence is invalid.") + if (plan.disposition == "trash") != ( + plan.trash_path is not None and plan.trash_stage is not None + ) or (plan.disposition != "trash" and (plan.trash_path or plan.trash_stage)): + raise ValidationError("Library removal disposition is invalid.") if ( (operation_id is not None and str(plan.operation_id) != operation_id) or any( not path.is_absolute() or ".." in path.parts - for path in (plan.source, plan.stage, plan.root_path) + for path in (plan.source, plan.stage, plan.root_path, plan.trash_path, plan.trash_stage) + if path is not None ) or plan.stage != plan.source.parent / f".pullbox-removal-{plan.operation_id.hex}" / "payload" @@ -146,6 +200,8 @@ def _check_locations(plan: RemovalPlan) -> None: raise ValidationError("Library removal locations changed; review before retrying.") if stat.S_IMODE(plan.stage.parent.stat().st_mode) != 0o700: raise ValidationError("Library removal staging must remain private.") + if plan.trash_stage and stat.S_IMODE(plan.trash_stage.parent.stat().st_mode) != 0o700: + raise ValidationError("Library removal trash staging must remain private.") async def _check_authority(session: AsyncSession, plan: RemovalPlan) -> None: @@ -175,7 +231,15 @@ async def record_removal(session: AsyncSession, plan: RemovalPlan) -> None: decode_removal(plan.model_dump_json()) await lock_file_mutation_admission(session) await require_no_archive_publication( - session, plan.source, plan.stage.parent, include_descendants=True + session, + plan.source, + plan.stage.parent, + *( + (plan.trash_path, plan.trash_stage.parent) + if plan.trash_path and plan.trash_stage + else () + ), + include_descendants=True, ) await _check_authority(session, plan) if _fingerprint(plan.source) != plan.fingerprint or _fingerprint(plan.stage) is not None: @@ -290,6 +354,14 @@ async def require_no_library_removal( for reserved, descendants in ( (plan.source, stat.S_ISDIR(plan.fingerprint[5])), (plan.stage.parent, True), + *( + ( + (plan.trash_path, stat.S_ISDIR(plan.fingerprint[5])), + (plan.trash_stage.parent, True), + ) + if plan.trash_path and plan.trash_stage + else () + ), ): if any( target == reserved diff --git a/src/pullbox/services/library_removal_cleanup.py b/src/pullbox/services/library_removal_cleanup.py new file mode 100644 index 00000000..227e06a5 --- /dev/null +++ b/src/pullbox/services/library_removal_cleanup.py @@ -0,0 +1,269 @@ +"""Finish committed removal work outside database write transactions.""" + +import asyncio +import os +import time +from threading import Event +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.core.file_publication import rename_path_without_overwrite +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services.archive_metadata_binding import FileFingerprint +from pullbox.services.archive_metadata_publication import _file_work, _fingerprint +from pullbox.services.library_conversion_files import matches, sync_directory +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, +) +from pullbox.services.library_removal import RemovalPlan, _check_locations, decode_removal +from pullbox.services.library_removal_files import ( + copy_payload, + create_backup_stage, + payload_digest, + removal_claim, + remove_payload, + require_payload, +) + + +class CleanupProof(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + phase: Literal["copying", "prepared", "deleting"] + transfer: Literal["copy", "rename"] = "copy" + backup_fingerprint: FileFingerprint | None = None + backup_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") + trash_mtime_ns: int | None = Field(default=None, gt=0) + + +def _decode_proof(value: str | None, plan: RemovalPlan) -> CleanupProof | None: + if value is None: + return None + if len(value.encode("utf-8")) > 4096: + raise ValidationError("Removal cleanup evidence exceeds its limit.") + proof = CleanupProof.model_validate_json(value) + if plan.disposition == "trash": + if ( + proof.backup_fingerprint is None + or ( + proof.transfer == "copy" + and proof.phase != "copying" + and proof.backup_digest is None + ) + or (proof.transfer == "rename" and proof.phase == "copying") + ): + raise ValidationError("Removal backup evidence is incomplete.") + if proof.phase != "copying" and proof.trash_mtime_ns is None: + raise ValidationError("Removal backup retention evidence is incomplete.") + elif proof.phase != "deleting" or proof.backup_fingerprint or proof.backup_digest: + raise ValidationError("Removal cleanup evidence is invalid.") + return proof + + +def _same_filesystem(plan: RemovalPlan) -> bool: + assert plan.trash_path is not None + return plan.stage.parent.stat().st_dev == plan.trash_path.parent.stat().st_dev + + +async def _read(session: AsyncSession, operation_id: UUID) -> LibraryRemoval: + row = await session.scalar( + select(LibraryRemoval) + .where(LibraryRemoval.operation_id == str(operation_id)) + .execution_options(populate_existing=True) + ) + if row is None: + raise ValidationError("Removal journal was not found.") + return row + + +async def _save( + session: AsyncSession, + plan: RemovalPlan, + encoded: str, + previous: str | None, + proof: CleanupProof, + *, + complete: bool = False, +) -> str: + await lock_file_mutation_admission(session) + row = await _read(session, plan.operation_id) + if ( + not row.active + or row.state != "detached" + or row.plan_json != encoded + or row.cleanup_json != previous + ): + raise ValidationError("Removal cleanup evidence changed.") + current = proof.model_dump_json() + row.cleanup_json = current + if complete: + row.state, row.active = "complete", False + await finish_short_mutation(asyncio.create_task(session.commit())) + return current + + +async def _backup( + session: AsyncSession, + plan: RemovalPlan, + encoded: str, + previous: str | None, + proof: CleanupProof | None, +) -> tuple[str, CleanupProof]: + assert plan.trash_stage is not None and plan.trash_path is not None + if proof is None and _same_filesystem(plan): + proof = CleanupProof( + phase="prepared", + transfer="rename", + backup_fingerprint=plan.fingerprint, + trash_mtime_ns=int(time.time()) * 1_000_000_000, + ) + previous = await _save(session, plan, encoded, previous, proof) + if proof is None: + created = await finish_short_mutation( + asyncio.create_task(asyncio.to_thread(create_backup_stage, plan)) + ) + proof = CleanupProof(phase="copying", backup_fingerprint=created) + previous = await _save(session, plan, encoded, previous, proof) + + if proof.phase == "copying": + expected = proof.backup_fingerprint + assert expected is not None + + def reset_partial(stop: Event) -> FileFingerprint: + _check_locations(plan) + assert plan.trash_stage is not None + actual = _fingerprint(plan.trash_stage) + if actual is None or actual[:2] != expected[:2] or actual[5] != expected[5]: + raise ValidationError("Private removal backup changed.") + if plan.trash_stage.is_dir(): + for child in plan.trash_stage.iterdir(): + child_identity = _fingerprint(child) + if child_identity is None: + raise ValidationError("Private removal backup changed.") + remove_payload(child, child_identity, stop, partial=True) + return actual + + # Reset only the inode we created, never an unexplained replacement. + created = await _file_work(reset_partial) + proof = CleanupProof(phase="copying", backup_fingerprint=created) + previous = await _save(session, plan, encoded, previous, proof) + fingerprint, digest = await _file_work(lambda stop: copy_payload(plan, stop)) + proof = CleanupProof( + phase="prepared", + backup_fingerprint=fingerprint, + backup_digest=digest, + trash_mtime_ns=int(time.time()) * 1_000_000_000, + ) + previous = await _save(session, plan, encoded, previous, proof) + + if proof.phase == "prepared" and _fingerprint(plan.trash_path) is None: + stamp = int(time.time()) * 1_000_000_000 + if proof.trash_mtime_ns != stamp: + proof = proof.model_copy(update={"trash_mtime_ns": stamp}) + previous = await _save(session, plan, encoded, previous, proof) + assert proof.backup_fingerprint is not None + assert proof.trash_mtime_ns is not None + expected_backup, expected_digest = proof.backup_fingerprint, proof.backup_digest + published_backup: FileFingerprint = ( + *expected_backup[:3], + proof.trash_mtime_ns, + expected_backup[4], + expected_backup[5], + ) + + def publish_and_check(stop: Event) -> None: + _check_locations(plan) + assert plan.trash_path is not None and plan.trash_stage is not None + if _fingerprint(plan.trash_path) is None: + if proof.phase == "deleting": + raise ValidationError("Published removal backup is missing.") + source = plan.stage if proof.transfer == "rename" else plan.trash_stage + require_payload(source, expected_backup) + if expected_digest and payload_digest(source, stop) != expected_digest: + raise ValidationError("Prepared removal backup changed.") + rename_path_without_overwrite(source, plan.trash_path) + sync_directory(plan.trash_path.parent) + sync_directory(source.parent) + actual = _fingerprint(plan.trash_path) + if matches(actual, expected_backup): + info = plan.trash_path.lstat() + os.utime( + plan.trash_path, ns=(info.st_atime_ns, published_backup[3]), follow_symlinks=False + ) + fd = os.open(plan.trash_path, os.O_RDONLY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + require_payload(plan.trash_path, published_backup) + if expected_digest and payload_digest(plan.trash_path, stop) != expected_digest: + raise ValidationError("Published removal backup changed.") + _check_locations(plan) + + await _file_work(publish_and_check) + assert previous is not None + return previous, proof.model_copy(update={"backup_fingerprint": published_backup}) + + +async def complete_removal(session: AsyncSession, operation_id: UUID) -> str: + """Own clean-session transactions; caller must commit detachment first. + + Slow copying, verification and recursive cleanup run without a DB transaction. + Durable proof plus a non-expiring filesystem claim makes retries repeatable. + Retain-only historical intents never authorize destructive cleanup. + """ + if session.new or session.dirty or session.deleted or session.in_nested_transaction(): + raise ValidationError("Removal cleanup requires a clean, committed session.") + row = await _read(session, operation_id) + state, active, encoded = row.state, row.active, row.plan_json + await session.commit() + if state == "complete" and not active: + return state + if state != "detached" or not active: + raise ValidationError("Removal must have committed detachment before cleanup.") + plan = decode_removal(encoded, operation_id=str(operation_id)) + if plan.disposition == "retain": + raise ValidationError("This retained removal does not authorize cleanup.") + async with removal_claim(plan): + try: + row = await _read(session, operation_id) + if row.plan_json != encoded or row.state != "detached" or not row.active: + raise ValidationError("Removal cleanup changed while acquiring its claim.") + previous = row.cleanup_json + proof = _decode_proof(previous, plan) + await session.commit() + if proof is None or proof.phase == "copying": + _check_locations(plan) + require_payload(plan.stage, plan.fingerprint) + if plan.disposition == "trash": + previous, proof = await _backup(session, plan, encoded, previous, proof) + if proof is None or proof.phase != "deleting": + if proof and proof.transfer == "copy": + require_payload(plan.stage, plan.fingerprint) + proof = ( + proof.model_copy(update={"phase": "deleting"}) + if proof + else CleanupProof(phase="deleting") + ) + previous = await _save(session, plan, encoded, previous, proof) + + def clean(stop: Event) -> None: + _check_locations(plan) + if plan.trash_path: + assert proof.backup_fingerprint is not None + require_payload(plan.trash_path, proof.backup_fingerprint) + if _fingerprint(plan.stage) is not None: + remove_payload(plan.stage, plan.fingerprint, stop, partial=True) + _check_locations(plan) + + await _file_work(clean) + await _save(session, plan, encoded, previous, proof, complete=True) + return "complete" + except BaseException: + await session.rollback() + raise diff --git a/src/pullbox/services/library_removal_files.py b/src/pullbox/services/library_removal_files.py new file mode 100644 index 00000000..b2f5120c --- /dev/null +++ b/src/pullbox/services/library_removal_files.py @@ -0,0 +1,239 @@ +"""Private removal work with bounded copies and cooperative cancellation.""" + +import asyncio +import hashlib +import os +import stat +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from threading import Event + +from pullbox.core.exceptions import ValidationError +from pullbox.services.archive_metadata_binding import FileFingerprint +from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.library_conversion_files import matches, sync_directory +from pullbox.services.library_mutation_coordination import finish_short_mutation +from pullbox.services.library_removal import RemovalPlan, _check_locations + + +def check_stop(stop: Event) -> None: + if stop.is_set(): + raise InterruptedError("Library removal cleanup cancelled") + + +def require_payload(path: Path, expected: FileFingerprint, *, partial: bool = False) -> None: + actual = _fingerprint(path) + if partial and stat.S_ISDIR(expected[5]): + valid = actual and actual[:2] == expected[:2] and actual[5] == expected[5] + else: + valid = matches(actual, expected) + if not valid: + raise ValidationError("The retained removal payload changed; review before retrying.") + + +def _claim(plan: RemovalPlan) -> int: + # A filesystem lock spans slow work without holding SQLite's writer mutex. + # Failing closed is preferable to a time-based lease that can expire on a live worker. + if os.name != "posix": + raise ValidationError("Removal cleanup requires filesystem lock support.") + import fcntl + + _check_locations(plan) + path = plan.stage.parent / "cleanup.lock" + fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValidationError("Removal cleanup lock changed.") + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + _check_locations(plan) + if path.lstat().st_ino != info.st_ino: + raise ValidationError("Removal cleanup lock changed.") + return fd + except BaseException: + os.close(fd) + raise + + +@asynccontextmanager +async def removal_claim(plan: RemovalPlan) -> AsyncIterator[None]: + task = asyncio.create_task(asyncio.to_thread(_claim, plan)) + fd: int | None = None + try: + try: + fd = await asyncio.shield(task) + except asyncio.CancelledError: + while not task.done(): + with suppress(asyncio.CancelledError, Exception): + await asyncio.shield(task) + if not task.cancelled() and task.exception() is None: + fd = task.result() + raise + yield + finally: + if fd is not None: + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(os.close, fd))) + + +def create_backup_stage(plan: RemovalPlan) -> FileFingerprint: + _check_locations(plan) + assert plan.trash_stage is not None + if stat.S_ISDIR(plan.fingerprint[5]): + plan.trash_stage.mkdir(mode=0o700) + else: + fd = os.open(plan.trash_stage, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + sync_directory(plan.trash_stage.parent) + result = _fingerprint(plan.trash_stage) + assert result is not None + return result + + +def _copy_file(source: Path, destination: Path, stop: Event, *, exists: bool = False) -> None: + check_stop(stop) + before = _fingerprint(source) + if before is None or not stat.S_ISREG(before[5]): + raise ValidationError("Removal backup source changed.") + source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW) + try: + if os.fstat(source_fd).st_ino != before[1]: + raise ValidationError("Removal backup source changed.") + target_fd = os.open( + destination, + os.O_WRONLY | os.O_NOFOLLOW | (os.O_TRUNC if exists else os.O_CREAT | os.O_EXCL), + stat.S_IMODE(before[5]) & 0o777, + ) + with os.fdopen(target_fd, "wb") as output: + while chunk := os.read(source_fd, 1024 * 1024): + check_stop(stop) + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + finally: + os.close(source_fd) + if _fingerprint(source) != before: + raise ValidationError("Removal backup source changed during copying.") + + +def copy_payload(plan: RemovalPlan, stop: Event) -> tuple[FileFingerprint, str]: + _check_locations(plan) + require_payload(plan.stage, plan.fingerprint) + assert plan.trash_stage is not None + + def copy(source: Path, target: Path, depth: int, *, exists: bool = False) -> None: + check_stop(stop) + if depth > 128: + raise ValidationError("Removal backup contains too many nested folders.") + mode = source.lstat().st_mode + if source.lstat().st_dev != plan.fingerprint[0]: + raise ValidationError("Removal backup cannot cross a nested filesystem mount.") + if stat.S_ISDIR(mode): + if not exists: + target.mkdir(mode=stat.S_IMODE(mode) & 0o777) + with os.scandir(source) as entries: + for entry in entries: + copy(source / entry.name, target / entry.name, depth + 1) + sync_directory(target) + elif stat.S_ISREG(mode): + _copy_file(source, target, stop, exists=exists) + elif stat.S_ISLNK(mode): + os.symlink(os.readlink(source), target) + else: + raise ValidationError("Removal backup contains an unsupported filesystem entry.") + + copy(plan.stage, plan.trash_stage, 0, exists=True) + digest = payload_digest(plan.stage, stop) + if payload_digest(plan.trash_stage, stop) != digest: + raise ValidationError("Removal backup does not match the retained source.") + require_payload(plan.stage, plan.fingerprint) + _check_locations(plan) + result = _fingerprint(plan.trash_stage) + assert result is not None + return result, digest + + +def payload_digest(path: Path, stop: Event) -> str: + digest = hashlib.sha256() + + def visit(item: Path, depth: int) -> None: + check_stop(stop) + if depth > 128: + raise ValidationError("Removal backup contains too many nested folders.") + before = _fingerprint(item) + if before is None: + raise ValidationError("Removal backup changed.") + mode = before[5] + if stat.S_ISDIR(mode): + digest.update(b"directory\0") + children: list[Path] = [] + for child in item.iterdir(): + check_stop(stop) + if len(children) >= 100_000: + raise ValidationError("Removal backup folder exceeds the entry limit.") + children.append(child) + for child in sorted(children): + name = os.fsencode(child.name) + digest.update(len(name).to_bytes(8, "big") + name) + visit(child, depth + 1) + digest.update(b"end\0") + elif stat.S_ISREG(mode): + digest.update(b"file\0" + before[2].to_bytes(8, "big")) + fd = os.open(item, os.O_RDONLY | os.O_NOFOLLOW) + try: + if os.fstat(fd).st_ino != before[1]: + raise ValidationError("Removal backup changed.") + while chunk := os.read(fd, 1024 * 1024): + check_stop(stop) + digest.update(chunk) + finally: + os.close(fd) + elif stat.S_ISLNK(mode): + link = os.fsencode(os.readlink(item)) + digest.update(b"link\0" + len(link).to_bytes(8, "big") + link) + else: + raise ValidationError("Removal backup contains an unsupported filesystem entry.") + if _fingerprint(item) != before: + raise ValidationError("Removal backup changed while checking it.") + + visit(path, 0) + return digest.hexdigest() + + +def remove_payload(path: Path, expected: FileFingerprint, stop: Event, *, partial: bool) -> None: + """Unlink within directory descriptors; never follow a nested symlink.""" + check_stop(stop) + require_payload(path, expected, partial=partial) + + def remove(parent: int, name: str, depth: int) -> None: + check_stop(stop) + if depth > 128: + raise ValidationError("Removal cleanup contains too many nested folders.") + before = os.stat(name, dir_fd=parent, follow_symlinks=False) + if before.st_dev != expected[0]: + raise ValidationError("Removal cleanup cannot cross a nested filesystem mount.") + if not stat.S_ISDIR(before.st_mode): + os.unlink(name, dir_fd=parent) + return + fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + try: + opened = os.fstat(fd) + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + raise ValidationError("Removal cleanup folder changed.") + with os.scandir(fd) as entries: + for entry in entries: + remove(fd, entry.name, depth + 1) + os.fsync(fd) + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino): + raise ValidationError("Removal cleanup folder changed.") + os.rmdir(name, dir_fd=parent) + finally: + os.close(fd) + + parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + remove(parent, path.name, 0) + os.fsync(parent) + finally: + os.close(parent) diff --git a/tests/integration/metadata_identity/test_library_removal_cleanup.py b/tests/integration/metadata_identity/test_library_removal_cleanup.py new file mode 100644 index 00000000..8b39717a --- /dev/null +++ b/tests/integration/metadata_identity/test_library_removal_cleanup.py @@ -0,0 +1,362 @@ +"""Committed cleanup uses retained private artifacts, never the old public path.""" + +import asyncio +import os +import sys +import threading +import time +from contextlib import asynccontextmanager + +import pytest +from sqlalchemy import select, update + +from pullbox.core.exceptions import ValidationError +from pullbox.models import LibraryFile +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services import library_removal_cleanup as cleanup +from pullbox.services.library_removal import prepare_removal, record_removal, stage_removal +from pullbox.services.library_removal_cleanup import complete_removal +from pullbox.services.library_removal_files import check_stop +from tests.unit.test_library_delete_service import _seed_root, _seed_series_issue_file + + +@pytest.fixture(autouse=True) +def cross_device_branch(monkeypatch): + # Exercise real copying and publication even when pytest's temp directories share a disk. + monkeypatch.setattr(cleanup, "_same_filesystem", lambda plan: False) + + +@asynccontextmanager +async def detached( + factory, tmp_path, *, folder=False, trash=False, commit=True, extra_link=None, old_mtime=False +): + root_path = tmp_path.resolve() / "library" + root_path.mkdir() + parent = root_path / "series" + parent.mkdir() + file = parent / "issue.cbz" + file.write_bytes(b"original comic") + if folder: + (parent / "second.cbz").write_bytes(b"second comic") + if extra_link is not None: + (parent / "external-link").symlink_to(extra_link) + if old_mtime: + os.utime(parent if folder else file, (1, 1)) + async with factory.begin() as session: + root = await _seed_root(session, root_path) + _, _, linked = await _seed_series_issue_file(session, root, file) + root_id, file_id = root.id, linked.id + plan = prepare_removal( + parent if folder else file, + root_id=root_id, + root_path=root_path, + disposition="trash" if trash else "delete", + trash_path=tmp_path.resolve() / "trash" / parent.name if trash else None, + ) + async with factory.begin() as session: + await record_removal(session, plan) + async with factory() as session: + await stage_removal(session, plan.operation_id) + await session.delete(await session.get(LibraryFile, file_id)) + if commit: + await session.commit() + else: + await session.rollback() + yield plan + + +@pytest.mark.parametrize("folder", [False, True]) +@pytest.mark.parametrize("trash", [False, True]) +async def test_committed_cleanup_finishes_and_preserves_replacement( + identity_probe_db, tmp_path, folder, trash +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, folder=folder, trash=trash) as plan: + if folder: + plan.source.mkdir() + replacement = plan.source / "issue.cbz" + else: + replacement = plan.source + replacement.write_bytes(b"new public copy") + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert replacement.read_bytes() == b"new public copy" + assert not plan.stage.exists() + if trash: + backup = plan.trash_path / "issue.cbz" if folder else plan.trash_path + assert backup.read_bytes() == b"original comic" + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert row.state == "complete" and not row.active + assert await complete_removal(session, plan.operation_id) == "complete" + + +async def test_uncommitted_removal_cannot_be_cleaned_up(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, commit=False) as plan: + async with factory() as session: + with pytest.raises(ValidationError, match="committed"): + await complete_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"original comic" + + +async def test_trash_collision_never_replaces_or_deletes_original(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + plan.trash_path.write_bytes(b"existing trash") + async with factory() as session: + with pytest.raises((FileExistsError, ValidationError)): + await complete_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"original comic" + assert plan.trash_path.read_bytes() == b"existing trash" + + +async def test_changed_private_payload_never_deleted(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + plan.stage.rename(plan.stage.with_name("saved-original")) + plan.stage.write_bytes(b"replacement payload") + async with factory() as session: + with pytest.raises(ValidationError): + await complete_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"replacement payload" + + +async def test_slow_copy_releases_database_and_serializes_workers( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + entered, release = threading.Event(), threading.Event() + real = cleanup.copy_payload + + def pause(plan, stop): + entered.set() + assert release.wait(10) + return real(plan, stop) + + monkeypatch.setattr(cleanup, "copy_payload", pause) + async with factory() as session: + task = asyncio.create_task(complete_removal(session, plan.operation_id)) + assert await asyncio.to_thread(entered.wait, 10) + try: + + async def write(): + async with factory.begin() as other: + await other.execute(update(LibraryRemoval).values(active=True)) + + await asyncio.wait_for(write(), 2) + async with factory() as other: + with pytest.raises(BlockingIOError): + await complete_removal(other, plan.operation_id) + finally: + release.set() + assert await asyncio.wait_for(task, 10) == "complete" + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_cancelled_copy_retains_source_and_retries_owned_partial_backup( + identity_probe_db, tmp_path, monkeypatch, folder +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=folder) as plan: + entered, stopped = threading.Event(), threading.Event() + real = cleanup.copy_payload + + def cancel_copy(plan, stop): + partial = plan.trash_stage / "partial.cbz" if folder else plan.trash_stage + partial.write_bytes(b"partial copy") + entered.set() + try: + assert stop.wait(10) + check_stop(stop) + finally: + stopped.set() + + monkeypatch.setattr(cleanup, "copy_payload", cancel_copy) + async with factory() as session: + task = asyncio.create_task(complete_removal(session, plan.operation_id)) + assert await asyncio.to_thread(entered.wait, 10) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 10) + assert stopped.is_set() and plan.stage.exists() + monkeypatch.setattr(cleanup, "copy_payload", real) + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + backup = plan.trash_path / "issue.cbz" if folder else plan.trash_path + assert backup.read_bytes() == b"original comic" + assert not (plan.trash_path / "partial.cbz").exists() + + +@pytest.mark.parametrize("trash", [False, True]) +async def test_interrupted_recursive_cleanup_resumes_without_touching_public_replacement( + identity_probe_db, tmp_path, monkeypatch, trash +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, folder=True, trash=trash) as plan: + real = cleanup.remove_payload + + def interrupted(path, expected, stop, *, partial): + if path == plan.stage: + (path / "issue.cbz").unlink() + raise OSError("injected interruption after first file") + return real(path, expected, stop, partial=partial) + + monkeypatch.setattr(cleanup, "remove_payload", interrupted) + async with factory() as session: + with pytest.raises(OSError, match="injected"): + await complete_removal(session, plan.operation_id) + plan.source.mkdir() + (plan.source / "replacement.cbz").write_bytes(b"replacement") + monkeypatch.setattr(cleanup, "remove_payload", real) + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert (plan.source / "replacement.cbz").read_bytes() == b"replacement" + if trash: + assert (plan.trash_path / "issue.cbz").read_bytes() == b"original comic" + assert (plan.trash_path / "second.cbz").read_bytes() == b"second comic" + + +@pytest.mark.parametrize("rename", [False, True]) +async def test_published_trash_survives_lost_database_acknowledgement( + identity_probe_db, tmp_path, monkeypatch, rename +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + monkeypatch.setattr(cleanup, "_same_filesystem", lambda plan: rename) + real = cleanup._save + + async def lost_response(session, plan, encoded, previous, proof, **kwargs): + if proof.phase == "deleting": + raise OSError("injected commit connection loss") + return await real(session, plan, encoded, previous, proof, **kwargs) + + monkeypatch.setattr(cleanup, "_save", lost_response) + async with factory() as session: + with pytest.raises(OSError, match="injected"): + await complete_removal(session, plan.operation_id) + assert plan.stage.exists() is not rename + assert plan.trash_path.read_bytes() == b"original comic" + monkeypatch.setattr(cleanup, "_save", real) + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + + +@pytest.mark.parametrize("change", ["contents", "missing", "replacement"]) +async def test_changed_published_backup_prevents_source_cleanup( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + real = cleanup.remove_payload + + def fail(*args, **kwargs): + raise OSError("injected pause before source cleanup") + + monkeypatch.setattr(cleanup, "remove_payload", fail) + async with factory() as session: + with pytest.raises(OSError): + await complete_removal(session, plan.operation_id) + if change in {"missing", "replacement"}: + plan.trash_path.unlink() + if change in {"contents", "replacement"}: + plan.trash_path.write_bytes(b"changed backup") + monkeypatch.setattr(cleanup, "remove_payload", real) + async with factory() as session: + with pytest.raises(ValidationError): + await complete_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"original comic" + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_same_filesystem_trash_uses_exclusive_rename_without_copying( + identity_probe_db, tmp_path, monkeypatch, folder +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=folder) as plan: + monkeypatch.setattr(cleanup, "_same_filesystem", lambda plan: True) + + def no_copy(*args): + pytest.fail("Same-filesystem trash must not copy a potentially huge collection") + + monkeypatch.setattr(cleanup, "copy_payload", no_copy) + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert plan.trash_path.stat().st_ino == plan.fingerprint[1] + + +@pytest.mark.parametrize("trash", [False, True]) +async def test_nested_symlinks_never_modify_external_files(identity_probe_db, tmp_path, trash): + _, factory, _ = identity_probe_db + outside = tmp_path / "external.cbz" + outside.write_bytes(b"external data") + async with detached(factory, tmp_path, trash=trash, folder=True, extra_link=outside) as plan: + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert outside.read_bytes() == b"external data" + if trash: + assert (plan.trash_path / "external-link").is_symlink() + + +async def test_cleanup_claim_fences_another_process_and_releases_after_exit( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + script = ( + "import sys; from pullbox.services.library_removal import decode_removal; " + "from pullbox.services.library_removal_files import _claim; " + "fd = _claim(decode_removal(sys.stdin.readline())); " + "print('claimed', flush=True); sys.stdin.readline()" + ) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + script, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + process.stdin.write(plan.model_dump_json().encode() + b"\n") + await process.stdin.drain() + assert await asyncio.wait_for(process.stdout.readline(), 10) == b"claimed\n" + async with factory() as session: + with pytest.raises(BlockingIOError): + await complete_removal(session, plan.operation_id) + assert plan.stage.exists() + finally: + process.kill() + await process.communicate() + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + + +async def test_old_retain_only_plans_never_authorize_cleanup(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + historical = plan.model_dump(mode="json") + for key in ("disposition", "trash_path", "trash_stage"): + historical.pop(key) + import json + + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(plan_json=json.dumps(historical))) + async with factory() as session: + with pytest.raises(ValidationError, match="does not authorize"): + await complete_removal(session, plan.operation_id) + assert plan.stage.read_bytes() == b"original comic" + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_trash_retention_starts_at_publication_not_original_mtime( + identity_probe_db, tmp_path, monkeypatch, folder +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=folder, old_mtime=True) as plan: + monkeypatch.setattr(cleanup, "_same_filesystem", lambda plan: True) + before = int(time.time()) + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert plan.trash_path.stat().st_mtime >= before diff --git a/tests/integration/metadata_identity/test_library_removal_cleanup_migration.py b/tests/integration/metadata_identity/test_library_removal_cleanup_migration.py new file mode 100644 index 00000000..0c719b05 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_removal_cleanup_migration.py @@ -0,0 +1,65 @@ +"""Retained cleanup evidence must survive upgrades and forbid unsafe downgrade.""" + +import importlib.util +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.models.library_removal import LibraryRemoval +from tests.integration.metadata_identity.test_library_removal import prepared, record + + +def revision(connection): + path = Path(__file__).resolve().parents[3] / ( + "alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py" + ) + spec = importlib.util.spec_from_file_location("removal_cleanup_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_cleanup_migration_preserves_retained_intents(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + columns = await connection.run_sync( + lambda conn: { + item["name"] for item in inspect(conn).get_columns("library_removals") + } + ) + assert "cleanup_json" not in columns + await connection.run_sync(lambda conn: revision(conn).upgrade()) + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert row.plan_json == plan.model_dump_json() and row.cleanup_json is None + assert row.active and row.state == "intended" + + +async def test_cleanup_evidence_prevents_downgrade(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(cleanup_json="{}")) + with pytest.raises(RuntimeError, match="cleanup evidence"): + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + async with factory() as session: + assert await session.scalar(select(LibraryRemoval.cleanup_json)) == "{}" + + +async def test_cleanup_storage_rejects_unbounded_evidence(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with prepared(factory, tmp_path) as (plan, _): + await record(factory, plan) + with pytest.raises(IntegrityError): + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(cleanup_json="x" * 4097)) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 4b41fd09..e4ea4872 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["c0w1x2y3z456"] + assert script.get_heads() == ["d1x2y3z4a567"] + assert script.get_revision("d1x2y3z4a567").down_revision == "c0w1x2y3z456" assert script.get_revision("c0w1x2y3z456").down_revision == "b9v0w1x2y345" assert script.get_revision("b9v0w1x2y345").down_revision == "a8u9v0w1x234" assert script.get_revision("a8u9v0w1x234").down_revision == "z7t8u9v0w123" From e35103897d3111283989812f11c83146656accb0 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 13:09:12 -0700 Subject: [PATCH 78/97] fix(library): protect recovery artifacts during trash cleanup --- ...y3z4a5b678_index_removal_trash_receipts.py | 57 +++ docs/development/ARCHITECTURE_OVERVIEW.md | 12 +- docs/development/DATABASE_STANDARDS.md | 17 +- src/pullbox/app.py | 5 +- src/pullbox/models/library_removal.py | 1 + src/pullbox/services/library_removal.py | 13 +- src/pullbox/services/library_trash_cleanup.py | 165 ++++++++ src/pullbox/services/library_trash_files.py | 139 +++++++ .../partials/settings_utilities.html | 8 +- src/pullbox/utilities/router.py | 21 +- src/pullbox/utilities/settings.py | 139 +------ tests/e2e/test_settings_page.py | 23 +- .../test_library_trash_cleanup.py | 388 ++++++++++++++++++ .../test_library_trash_migration.py | 47 +++ tests/integration/test_migrations.py | 3 +- tests/unit/test_app_lifespan.py | 4 +- tests/utilities/test_router_branches.py | 28 +- tests/utilities/test_utility_settings.py | 25 +- 18 files changed, 928 insertions(+), 167 deletions(-) create mode 100644 alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py create mode 100644 src/pullbox/services/library_trash_cleanup.py create mode 100644 src/pullbox/services/library_trash_files.py create mode 100644 tests/integration/metadata_identity/test_library_trash_cleanup.py create mode 100644 tests/integration/metadata_identity/test_library_trash_migration.py diff --git a/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py b/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py new file mode 100644 index 00000000..e2599c98 --- /dev/null +++ b/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py @@ -0,0 +1,57 @@ +"""Index retained removal trash receipts for bounded retention lookups.""" + +import hashlib +import json +import os +from pathlib import Path + +import sqlalchemy as sa + +from alembic import op + +revision = "e2y3z4a5b678" +down_revision = "d1x2y3z4a567" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("library_removals", sa.Column("trash_path_key", sa.String(64), nullable=True)) + table = sa.table( + "library_removals", sa.column("id"), sa.column("plan_json"), sa.column("trash_path_key") + ) + connection = op.get_bind() + last_id = 0 + while rows := connection.execute( + sa.select(table.c.id, table.c.plan_json) + .where(table.c.id > last_id) + .order_by(table.c.id) + .limit(8) + ).all(): + for row_id, encoded in rows: + last_id = row_id + if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536: + raise RuntimeError("Invalid retained removal evidence; repair before upgrading.") + plan = json.loads(encoded) + if not isinstance(plan, dict): + raise RuntimeError("Invalid retained removal plan; repair before upgrading.") + value = plan.get("trash_path") + if value is None: + continue + if ( + not isinstance(value, str) + or not Path(value).is_absolute() + or ".." in Path(value).parts + ): + raise RuntimeError("Invalid retained trash path; repair before upgrading.") + connection.execute( + table.update() + .where(table.c.id == row_id) + .values(trash_path_key=hashlib.sha256(os.fsencode(value)).hexdigest()) + ) + op.create_index("ix_library_removals_trash_path_key", "library_removals", ["trash_path_key"]) + + +def downgrade() -> None: + op.drop_index("ix_library_removals_trash_path_key", table_name="library_removals") + op.drop_column("library_removals", "trash_path_key") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index 62733739..e12037ed 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -639,7 +639,17 @@ renames rather than copying large collections. Cancellation leaves owned partial copies/deletions retryable, and cleanup never follows the old public source path. The browser, series and bulk deletion commands still need to adopt this lifecycle; the primitives alone do not enable paired production writers or change public -deletion semantics. Trash-retention coordination and non-POSIX locking remain open. +deletion semantics. Non-POSIX removal locking remains open. + +`library_trash_cleanup.py` owns startup retention and interactive trash cleanup. +It checks retained publication/conversion/removal reservations under shared +admission before each short unlink, keeps private preparation directories, and +refuses registered files or library roots inside trash. Directory traversal runs +off-thread in bounded pages without a database transaction. Completed removal +receipts protect a newly trashed tree for its full retention window even when +child files have old modification times. The settings surface reports retained +entries instead of claiming that protected trash was emptied. Public deletion +owner integration and terminal private-workspace cleanup remain separate work. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 98c8b21e..6330dab4 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -621,10 +621,23 @@ Cancellation joins workers and leaves retryable proof. Unknown replacements are preserved. The old public source path is never used for cleanup. These are owner-integration primitives, not yet the public delete implementation. -Series/browser/bulk deletion and trash retention must adopt the complete lifecycle -before paired background writing is enabled. POSIX file locking is implemented; +Series/browser/bulk deletion must adopt the complete lifecycle before paired +background writing is enabled. POSIX file locking is implemented; other platforms currently fail closed at private cleanup admission. +Trash maintenance now owns an idle session and commits each short, identity-checked +unlink separately. The startup and utility route callers finish configuration +reads before entering that lifecycle; the service rejects an existing transaction +instead of committing unrelated changes. Shared admission protects recovery +dependencies until deletion finishes, including cancellation. Traversal and +bounded directory-page construction occur outside transactions. The indexed +`library_removals.trash_path_key` is a derived literal-path lookup, not identity +authority: the bounded original plan and cleanup receipt are revalidated before +using their publication time to protect a whole trash tree. Upgrade backfills this +index in bounded batches without filesystem access; downgrade discards only the +derived key, never the retained plan/receipt. Existing legacy trash files keep +mtime-based retention until their writers adopt journaled ownership. + ### Source Catalog Checkpoints `series_catalog_checkpoints` records complete issue-catalog progress separately diff --git a/src/pullbox/app.py b/src/pullbox/app.py index 6424d83f..1e9661a0 100644 --- a/src/pullbox/app.py +++ b/src/pullbox/app.py @@ -321,8 +321,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: logger.warning("cover_migration_failed", subsystem="cover_migration", exc_info=True) # Ensure utility directories (trash and export) exist + from pullbox.services.library_trash_cleanup import cleanup_trash from pullbox.utilities.settings import ( - cleanup_utility_trash_retention, ensure_utility_directories, resolve_utility_directory, ) @@ -363,7 +363,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: ) await ensure_utility_directories(trash_dir, export_dir) retention_days = int(util_cfg.get("utility_trash_retention_days", "30") or "30") - cleanup_utility_trash_retention(trash_dir, retention_days) + async with factory() as session: + await cleanup_trash(session, trash_dir, retention_days=retention_days) except Exception: logger.warning("utility_directory_creation_failed", subsystem="utility_dirs", exc_info=True) diff --git a/src/pullbox/models/library_removal.py b/src/pullbox/models/library_removal.py index 7b08c930..fad44695 100644 --- a/src/pullbox/models/library_removal.py +++ b/src/pullbox/models/library_removal.py @@ -27,3 +27,4 @@ class LibraryRemoval(Base, IdentityMixin, TimestampMixin): state: Mapped[str] = mapped_column(String(10), default="intended") plan_json: Mapped[str] = mapped_column(Text) cleanup_json: Mapped[str | None] = mapped_column(Text, nullable=True) + trash_path_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) diff --git a/src/pullbox/services/library_removal.py b/src/pullbox/services/library_removal.py index f42a124e..decca798 100644 --- a/src/pullbox/services/library_removal.py +++ b/src/pullbox/services/library_removal.py @@ -1,6 +1,8 @@ """Durable staging and recovery boundary for library removal owners.""" import asyncio +import hashlib +import os import stat from contextlib import suppress from pathlib import Path @@ -28,6 +30,11 @@ logger = structlog.get_logger(__name__) +def trash_path_key(path: Path) -> str: + """Index literal trash paths without indexing arbitrarily long filenames.""" + return hashlib.sha256(os.fsencode(path)).hexdigest() + + class RemovalPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: Literal[1] = 1 @@ -245,7 +252,11 @@ async def record_removal(session: AsyncSession, plan: RemovalPlan) -> None: if _fingerprint(plan.source) != plan.fingerprint or _fingerprint(plan.stage) is not None: raise ValidationError("Library removal source or staging changed.") session.add( - LibraryRemoval(operation_id=str(plan.operation_id), plan_json=plan.model_dump_json()) + LibraryRemoval( + operation_id=str(plan.operation_id), + plan_json=plan.model_dump_json(), + trash_path_key=trash_path_key(plan.trash_path) if plan.trash_path else None, + ) ) await session.flush() session.info[f"removal_intent:{plan.operation_id}"] = session.sync_session.get_transaction() diff --git a/src/pullbox/services/library_trash_cleanup.py b/src/pullbox/services/library_trash_cleanup.py new file mode 100644 index 00000000..3f1a3f21 --- /dev/null +++ b/src/pullbox/services/library_trash_cleanup.py @@ -0,0 +1,165 @@ +"""Coordinated trash cleanup with short, explicitly owned transactions.""" + +import asyncio +import stat +import time +from dataclasses import dataclass +from itertools import islice +from pathlib import Path + +import structlog +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.models import LibraryFile, LibraryRoot +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, + require_no_archive_publication, +) +from pullbox.services.library_removal import decode_removal, trash_path_key +from pullbox.services.library_removal_cleanup import _decode_proof +from pullbox.services.library_trash_files import TrashEntry, remove_entry, walk_trash + +logger = structlog.get_logger(__name__) +_PAGE_SIZE = 64 + + +@dataclass +class TrashCleanupResult: + deleted_entries: int = 0 + retained_entries: int = 0 + + +async def _recent_trash_tree(session: AsyncSession, entry: TrashEntry, cutoff: int) -> bool: + ancestors = (entry.path, *entry.path.parents) + keys = [trash_path_key(path) for path in ancestors] + last_id = 0 + while True: + rows = ( + await session.execute( + select( + LibraryRemoval.id, + LibraryRemoval.trash_path_key, + case( + (func.length(LibraryRemoval.plan_json) <= 65536, LibraryRemoval.plan_json), + else_=None, + ), + case( + ( + func.length(LibraryRemoval.cleanup_json) <= 4096, + LibraryRemoval.cleanup_json, + ), + else_=None, + ), + ) + .where( + LibraryRemoval.id > last_id, + LibraryRemoval.trash_path_key.in_(keys), + LibraryRemoval.state == "complete", + LibraryRemoval.active.is_(False), + ) + .order_by(LibraryRemoval.id) + .limit(8) + ) + ).all() + if not rows: + return False + for row_id, key, encoded, evidence in rows: + last_id = row_id + if encoded is None: + raise ValidationError("Trash receipt needs review before retention cleanup.") + plan = decode_removal(encoded) + if plan.trash_path not in ancestors or trash_path_key(plan.trash_path) != key: + raise ValidationError("Trash receipt path changed; review it before cleanup.") + proof = _decode_proof(evidence, plan) + if proof is None or proof.trash_mtime_ns is None: + raise ValidationError("Trash receipt has no verified retention time.") + if proof.trash_mtime_ns > cutoff: + return True + + +async def cleanup_trash( + session: AsyncSession, trash_dir: Path, *, retention_days: int | None = None +) -> TrashCleanupResult: + """Own an idle session; commit each short deletion, never a whole tree walk. + + Callers must finish their read-only setup transaction first. Refuse pending + transactions rather than accidentally committing unrelated request changes. + """ + if session.in_transaction() or session.new or session.dirty or session.deleted: + raise ValidationError("Trash cleanup requires an idle session with committed setup.") + if retention_days is not None and not 1 <= retention_days <= 365: + raise ValidationError("Trash retention must be between 1 and 365 days.") + trash_dir = trash_dir.absolute() + async with session.begin(): + if await session.scalar(select(LibraryRoot.id).where(LibraryRoot.path == str(trash_dir))): + raise ValidationError( + "A library root cannot be emptied as trash. Choose a separate trash folder." + ) + cutoff = time.time_ns() - retention_days * 86400 * 1_000_000_000 if retention_days else None + result = TrashCleanupResult() + entries = walk_trash(trash_dir) + try: + while page := await finish_short_mutation( + asyncio.create_task(asyncio.to_thread(lambda: list(islice(entries, _PAGE_SIZE)))) + ): + for entry in page: + if entry.protected: + result.retained_entries += 1 + continue + try: + async with session.begin(): + await lock_file_mutation_admission(session) + await require_no_archive_publication( + session, entry.path, include_descendants=True + ) + if await session.scalar( + select(LibraryRoot.id).where( + LibraryRoot.path.in_( + [ + str(path) + for path in (entry.path, *entry.path.parents) + if path.is_relative_to(trash_dir) + ] + ) + ) + ): + raise ValidationError( + "A library root inside this trash location was left untouched." + ) + if await session.scalar( + select(LibraryFile.id).where(LibraryFile.file_path == str(entry.path)) + ): + raise ValidationError( + "A registered library file is not disposable trash." + ) + if cutoff is not None and ( + await _recent_trash_tree(session, entry, cutoff) + or ( + not stat.S_ISDIR(entry.fingerprint[5]) + and entry.fingerprint[3] > cutoff + ) + ): + continue + removed = await finish_short_mutation( + asyncio.create_task(asyncio.to_thread(remove_entry, entry)) + ) + result.deleted_entries += int(removed) + except (OSError, ValueError, ValidationError) as exc: + result.retained_entries += 1 + logger.warning( + "utility_trash_entry_retained", path=str(entry.path), error=str(exc) + ) + finally: + await finish_short_mutation(asyncio.create_task(asyncio.to_thread(entries.close))) + logger.info( + "utility_trash_cleanup_complete", + directory=str(trash_dir), + retention_days=retention_days, + deleted_entries=result.deleted_entries, + retained_entries=result.retained_entries, + ) + return result diff --git a/src/pullbox/services/library_trash_files.py b/src/pullbox/services/library_trash_files.py new file mode 100644 index 00000000..191f7cb5 --- /dev/null +++ b/src/pullbox/services/library_trash_files.py @@ -0,0 +1,139 @@ +"""Bounded, non-following trash traversal and identity-checked leaf deletion.""" + +import errno +import os +import stat +from collections.abc import Generator, Iterator +from dataclasses import dataclass +from pathlib import Path + +from pullbox.core.exceptions import ValidationError +from pullbox.services.archive_metadata_binding import FileFingerprint +from pullbox.services.library_conversion_files import directories + +_DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) +_PRIVATE_PREFIXES = (".pullbox-removal-", ".pullbox-conversion-", ".pullbox-metadata-") +type DirectoryProof = tuple[Path, int, int, int] + + +def _fingerprint(info: os.stat_result) -> FileFingerprint: + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_mode + + +@dataclass(frozen=True) +class TrashEntry: + path: Path + fingerprint: FileFingerprint + parents: tuple[DirectoryProof, ...] + protected: bool = False + + +def _open_checked(path: Path, parents: tuple[DirectoryProof, ...]) -> int: + expected = {item[0]: item[1:] for item in parents} + fd = os.open(path.anchor, _DIRECTORY_FLAGS) + current = Path(path.anchor) + try: + for part in (None, *path.parts[1:]): + if part is not None: + new_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=fd) + os.close(fd) + fd = new_fd + current /= part + info = os.fstat(fd) + if (info.st_dev, info.st_ino, info.st_mode) != expected.get(current): + raise ValidationError( + "Trash directory changed; recheck its location before retrying." + ) + return fd + except BaseException: + os.close(fd) + raise + + +def walk_trash(root: Path) -> Generator[TrashEntry, None, None]: + """Keep at most one open iterator per depth; private workspaces are never entered.""" + if os.name != "posix": + raise ValidationError("Safe trash cleanup is not available on this platform yet.") + if root == Path(root.anchor) or root.resolve() != root: + raise ValidationError( + "Trash must be a real directory, not a filesystem root or symbolic link." + ) + if not root.exists(): + return + proof = directories(root / ".trash-probe") + root_fd = _open_checked(root, proof) + device = os.fstat(root_fd).st_dev + + def visit( + fd: int, path: Path, parents: tuple[DirectoryProof, ...], depth: int + ) -> Iterator[TrashEntry]: + with os.scandir(fd) as children: + for child in children: + try: + info = child.stat(follow_symlinks=False) + except FileNotFoundError: + continue + blocked = ( + child.name.startswith(_PRIVATE_PREFIXES) or info.st_dev != device or depth > 128 + ) + entry = TrashEntry(path / child.name, _fingerprint(info), parents, blocked) + if not blocked and stat.S_ISDIR(info.st_mode): + try: + nested = os.open(child.name, _DIRECTORY_FLAGS, dir_fd=fd) + except OSError: + yield TrashEntry(entry.path, entry.fingerprint, parents, True) + continue + try: + actual = os.fstat(nested) + if (actual.st_dev, actual.st_ino, actual.st_mode) != ( + info.st_dev, + info.st_ino, + info.st_mode, + ): + yield TrashEntry(entry.path, entry.fingerprint, parents, True) + continue + yield from visit( + nested, + entry.path, + (*parents, (entry.path, info.st_dev, info.st_ino, info.st_mode)), + depth + 1, + ) + finally: + os.close(nested) + yield entry + + try: + yield from visit(root_fd, root, proof, 0) + finally: + os.close(root_fd) + + +def remove_entry(entry: TrashEntry) -> bool: + """Only unlink one unchanged entry or prune one empty directory, never recurse.""" + fd = _open_checked(entry.path.parent, entry.parents) + try: + try: + info = os.stat(entry.path.name, dir_fd=fd, follow_symlinks=False) + except FileNotFoundError: + return False + expected = entry.fingerprint + directory = stat.S_ISDIR(expected[5]) + if (info.st_dev, info.st_ino, info.st_mode) != (expected[0], expected[1], expected[5]) or ( + not directory and _fingerprint(info) != expected + ): + raise ValidationError("Trash entry changed during cleanup; it was left untouched.") + if directory: + try: + os.rmdir(entry.path.name, dir_fd=fd) + except OSError as exc: + if exc.errno in {errno.ENOTEMPTY, errno.EEXIST}: + return False + raise + elif stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + os.unlink(entry.path.name, dir_fd=fd) + else: + raise ValidationError("Unsupported trash entry was left untouched.") + os.fsync(fd) + return True + finally: + os.close(fd) diff --git a/src/pullbox/ui/templates/partials/settings_utilities.html b/src/pullbox/ui/templates/partials/settings_utilities.html index 40af13c2..2c007106 100644 --- a/src/pullbox/ui/templates/partials/settings_utilities.html +++ b/src/pullbox/ui/templates/partials/settings_utilities.html @@ -219,7 +219,7 @@ async emptyTrashNow() { const ok = await window.pbConfirm({ title: 'Empty Utility Trash', - message: 'Delete everything currently in the utility trash folder? This cannot be undone.', + message: 'Permanently delete disposable contents from the utility trash folder? Protected recovery files will be kept. This cannot be undone.', confirmText: 'Empty Trash', destructive: true, }); @@ -240,10 +240,12 @@ } if (typeof showToast === 'function') { showToast({ - message: data.deleted_entries > 0 + message: data.retained_entries > 0 + ? `Cleanup finished. Deleted ${data.deleted_entries} item${data.deleted_entries === 1 ? '' : 's'}; kept ${data.retained_entries} protected or changed entr${data.retained_entries === 1 ? 'y' : 'ies'}. See the logs for details.` + : data.deleted_entries > 0 ? `Trash emptied. Deleted ${data.deleted_entries} item${data.deleted_entries === 1 ? '' : 's'}.` : 'Trash is already empty.', - level: 'success', + level: data.retained_entries > 0 ? 'warning' : 'success', }); } } catch (err) { diff --git a/src/pullbox/utilities/router.py b/src/pullbox/utilities/router.py index 00890b2d..2b5b74ae 100644 --- a/src/pullbox/utilities/router.py +++ b/src/pullbox/utilities/router.py @@ -19,6 +19,7 @@ from pullbox.models.library import LibraryFile, LibraryFileStorageMode, LibraryRoot from pullbox.services.database_optimization_service import DatabaseOptimizationService from pullbox.services.health_helpers import _sqlite_database_path +from pullbox.services.library_trash_cleanup import cleanup_trash from pullbox.utilities.import_guards import ( ensure_no_active_import_file_mutation, ensure_utility_job_allowed_during_import, @@ -67,8 +68,6 @@ QueueStatusResponse, ) from pullbox.utilities.settings import ( - cleanup_utility_trash_retention, - empty_utility_trash, resolve_utility_directory, ) from pullbox.utilities.sse import subscribe @@ -168,7 +167,9 @@ async def _resolve_utility_trash_context(session: DbSession) -> tuple[Path, int] async def _enforce_utility_trash_retention(session: DbSession) -> int: trash_dir, retention_days = await _resolve_utility_trash_context(session) - return cleanup_utility_trash_retention(trash_dir, retention_days) + # Setup only reads configuration/guards; finish it before per-entry maintenance. + await session.commit() + return (await cleanup_trash(session, trash_dir, retention_days=retention_days)).deleted_entries async def _delete_job_records( @@ -235,10 +236,20 @@ async def empty_trash( await ensure_no_active_import_file_mutation(session) trash_dir, _retention_days = await _resolve_utility_trash_context(session) trash_dir.mkdir(parents=True, exist_ok=True) - deleted_entries = empty_utility_trash(trash_dir) + await session.commit() + result = await cleanup_trash(session, trash_dir) + if result.retained_entries: + return { + "message": ( + "Trash cleanup finished. Protected or changed entries were kept; " + "see the logs for details." + ), + "deleted_entries": result.deleted_entries, + "retained_entries": result.retained_entries, + } return { "message": "Trash emptied.", - "deleted_entries": deleted_entries, + "deleted_entries": result.deleted_entries, } diff --git a/src/pullbox/utilities/settings.py b/src/pullbox/utilities/settings.py index 000cbe7c..11c7d18e 100644 --- a/src/pullbox/utilities/settings.py +++ b/src/pullbox/utilities/settings.py @@ -9,7 +9,7 @@ import os import shutil -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path import structlog @@ -181,14 +181,6 @@ async def ensure_utility_directories( ) -def _log_trash_walk_error(error: OSError) -> None: - logger.warning( - "utility_trash_walk_failed", - path=getattr(error, "filename", None), - error=str(error), - ) - - def build_trash_destination( trash_dir: Path, source: Path, @@ -297,132 +289,3 @@ def restore_file_from_utility_trash( if temp_backup is not None and temp_backup.exists(): temp_backup.unlink() - - -def _remove_tree(path: Path) -> int: - """Delete a file or directory tree and return the removed-entry count.""" - deleted = 0 - - if not path.exists(): - return 0 - - if path.is_file() or path.is_symlink(): - path.unlink() - return 1 - - for root, dirnames, filenames in path.walk(top_down=False, on_error=_log_trash_walk_error): - dirnames.sort() - filenames.sort() - for filename in filenames: - file_path = root / filename - try: - file_path.unlink() - deleted += 1 - except OSError as exc: - logger.warning( - "utility_trash_delete_failed", - path=str(file_path), - error=str(exc), - ) - for dirname in dirnames: - dir_path = root / dirname - try: - dir_path.rmdir() - deleted += 1 - except OSError as exc: - logger.warning( - "utility_trash_delete_failed", - path=str(dir_path), - error=str(exc), - ) - try: - path.rmdir() - deleted += 1 - except OSError as exc: - logger.warning( - "utility_trash_delete_failed", - path=str(path), - error=str(exc), - ) - return deleted - - -def empty_utility_trash(trash_dir: Path) -> int: - """Delete all contents from the utility trash directory, preserving the root.""" - if not trash_dir.exists(): - return 0 - - deleted = 0 - for child in sorted(trash_dir.iterdir(), key=lambda entry: entry.name): - try: - deleted += _remove_tree(child) - except OSError as exc: - logger.warning( - "utility_trash_delete_failed", - path=str(child), - error=str(exc), - ) - - logger.info("utility_trash_emptied", directory=str(trash_dir), deleted_entries=deleted) - return deleted - - -def cleanup_utility_trash_retention( - trash_dir: Path, - retention_days: int, -) -> int: - """Delete trash files older than the retention window and prune empty folders.""" - if not trash_dir.exists(): - return 0 - - cutoff = datetime.now(UTC) - timedelta(days=retention_days) - deleted = 0 - - for root, dirnames, filenames in trash_dir.walk(top_down=False, on_error=_log_trash_walk_error): - dirnames.sort() - filenames.sort() - - for filename in filenames: - file_path = root / filename - try: - modified_at = datetime.fromtimestamp(file_path.stat().st_mtime, tz=UTC) - except OSError as exc: - logger.warning( - "utility_trash_stat_failed", - path=str(file_path), - error=str(exc), - ) - continue - if modified_at > cutoff: - continue - try: - file_path.unlink() - deleted += 1 - except OSError as exc: - logger.warning( - "utility_trash_delete_failed", - path=str(file_path), - error=str(exc), - ) - - for dirname in dirnames: - dir_path = root / dirname - try: - if dir_path.exists() and not any(dir_path.iterdir()): - dir_path.rmdir() - deleted += 1 - except OSError as exc: - logger.warning( - "utility_trash_prune_failed", - path=str(dir_path), - error=str(exc), - ) - - if deleted: - logger.info( - "utility_trash_retention_cleanup_complete", - directory=str(trash_dir), - retention_days=retention_days, - deleted_entries=deleted, - ) - return deleted diff --git a/tests/e2e/test_settings_page.py b/tests/e2e/test_settings_page.py index 74d3308a..f5662f99 100644 --- a/tests/e2e/test_settings_page.py +++ b/tests/e2e/test_settings_page.py @@ -1579,10 +1579,12 @@ def handle_directories(route) -> None: # type: ignore[no-untyped-def] assert requested_paths == [expected_trash, expected_export] + @pytest.mark.parametrize("retained", [0, 3]) def test_settings_utilities_empty_trash_uses_confirm_modal_and_endpoint( self, authed_page, seeded_server: str, # type: ignore[no-untyped-def] + retained: int, ) -> None: settings = SettingsPage(authed_page, seeded_server) settings.goto("utilities") @@ -1594,7 +1596,13 @@ def handle_empty_trash(route) -> None: # type: ignore[no-untyped-def] route.fulfill( status=200, content_type="application/json", - body=json.dumps({"message": "Trash emptied.", "deleted_entries": 3}), + body=json.dumps( + { + "message": "Trash emptied.", + "deleted_entries": 3, + "retained_entries": retained, + } + ), ) authed_page.route("**/api/v1/utilities/trash/empty", handle_empty_trash) @@ -1608,7 +1616,7 @@ def handle_empty_trash(route) -> None: # type: ignore[no-untyped-def] exact=True, ).is_visible() assert authed_page.get_by_text( - "Delete everything currently in the utility trash folder? This cannot be undone.", + "Permanently delete disposable contents from the utility trash folder? Protected recovery files will be kept. This cannot be undone.", exact=True, ).is_visible() authed_page.locator("#pb-confirm-dialog").get_by_role( @@ -1619,3 +1627,14 @@ def handle_empty_trash(route) -> None: # type: ignore[no-untyped-def] authed_page.wait_for_timeout(200) assert requests == ["POST"] + if retained: + expect( + authed_page.get_by_text( + "Cleanup finished. Deleted 3 items; kept 3 protected or changed entries. See the logs for details.", + exact=True, + ) + ).to_be_visible() + else: + expect( + authed_page.get_by_text("Trash emptied. Deleted 3 items.", exact=True) + ).to_be_visible() diff --git a/tests/integration/metadata_identity/test_library_trash_cleanup.py b/tests/integration/metadata_identity/test_library_trash_cleanup.py new file mode 100644 index 00000000..0b595307 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_trash_cleanup.py @@ -0,0 +1,388 @@ +"""Trash cleanup must never consume a recovery dependency or a fresh trash tree.""" + +import asyncio +import hashlib +import os +import threading +from uuid import uuid4 + +import pytest +from sqlalchemy import inspect, select + +from pullbox.core.exceptions import ValidationError +from pullbox.models.library_conversion import LibraryConversion +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services import library_trash_cleanup as cleanup +from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.library_conversion_files import ( + ConversionBinding, + ConversionFile, + ConversionPlan, + directories, +) +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission +from pullbox.services.library_removal import prepare_removal, record_removal +from pullbox.services.library_removal_cleanup import complete_removal +from pullbox.services.library_trash_cleanup import cleanup_trash +from tests.integration.metadata_identity.test_library_removal_cleanup import detached +from tests.unit.test_library_delete_service import _seed_root + + +async def test_empty_trash_preserves_active_removal_backup(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + plan.trash_path.write_bytes(b"backup awaiting cleanup") + async with factory() as session: + result = await cleanup_trash(session, plan.trash_path.parent) + assert plan.trash_path.exists(), "Active recovery backup must not be emptied" + assert result.retained_entries > 0 + + +async def test_fresh_completed_folder_keeps_old_children(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=True) as plan: + for child in plan.stage.iterdir(): + os.utime(child, (1, 1)) + async with factory() as session: + await complete_removal(session, plan.operation_id) + async with factory() as session: + result = await cleanup_trash(session, plan.trash_path.parent, retention_days=30) + assert (plan.trash_path / "issue.cbz").exists(), "Retention starts when folder was trashed" + assert result.deleted_entries == 0 + + +async def test_empty_trash_preserves_conversion_backup(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + root = tmp_path.resolve() / "library" + root.mkdir() + trash = root / ".trash" + trash.mkdir() + source = root / "comic.cbr" + backup = trash / source.name + source.write_bytes(b"original") + backup.write_bytes(b"original") + output_stage = root / ".pullbox-conversion-output" / "output.cbz" + backup_stage = trash / ".pullbox-conversion-backup" / "original" + output_stage.parent.mkdir() + backup_stage.parent.mkdir() + plan = ConversionPlan( + binding=ConversionBinding( + root_id=1, + root_path=str(root), + file_id=None, + issue_id=None, + file_format=None, + file_size=None, + file_modified_at=None, + ), + original=ConversionFile(path=source, fingerprint=_fingerprint(source), digest="a" * 64), + output=ConversionFile( + path=source.with_suffix(".cbz"), fingerprint=_fingerprint(source), digest="b" * 64 + ), + backup=ConversionFile(path=backup, fingerprint=_fingerprint(backup), digest="a" * 64), + output_stage=output_stage, + backup_stage=backup_stage, + directories=directories(source, backup), + ) + async with factory.begin() as session: + session.add(LibraryConversion(operation_id=str(uuid4()), plan_json=plan.model_dump_json())) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert backup.exists(), "Conversion backup is still required by its journal" + assert result.retained_entries > 0 + + +async def test_unrecorded_private_stage_is_not_trash(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + private = trash / ".pullbox-conversion-pending" / "original" + private.parent.mkdir(parents=True) + private.write_bytes(b"work before durable admission") + async with factory() as session: + result = await cleanup_trash(session, trash) + assert private.exists(), "Preparation work must survive before its journal is recorded" + assert result.retained_entries > 0 + + +async def test_trash_root_symlink_is_refused(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + outside = tmp_path / "outside" + outside.mkdir() + victim = outside / "keep.cbz" + victim.write_bytes(b"not trash") + trash = tmp_path / "trash" + trash.symlink_to(outside, target_is_directory=True) + async with factory() as session: + with pytest.raises(ValidationError): + await cleanup_trash(session, trash) + assert victim.exists() + + +async def test_legacy_retention_and_empty_trash_keep_existing_contract(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + nested = trash / "nested" + nested.mkdir(parents=True) + old = nested / "old.cbz" + old.write_bytes(b"old") + os.utime(old, (1, 1)) + fresh = trash / "fresh.cbz" + fresh.write_bytes(b"fresh") + async with factory() as session: + result = await cleanup_trash(session, trash, retention_days=30) + assert result.deleted_entries == 2 + assert not nested.exists() and fresh.exists() + async with factory() as session: + result = await cleanup_trash(session, trash) + assert result.deleted_entries == 1 + assert trash.is_dir() and not list(trash.iterdir()) + + +async def test_recorded_trash_path_has_indexed_receipt_lookup(identity_probe_db, tmp_path): + engine, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert ( + getattr(row, "trash_path_key", None) + == hashlib.sha256(os.fsencode(plan.trash_path)).hexdigest() + ) + async with engine.connect() as connection: + indexes = await connection.run_sync( + lambda conn: inspect(conn).get_indexes("library_removals") + ) + assert any(index["column_names"] == ["trash_path_key"] for index in indexes) + + +async def test_admission_is_rechecked_after_file_discovery( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + root = tmp_path.resolve() / "library" + trash = root / ".trash" + trash.mkdir(parents=True) + file = trash / "claimed.cbz" + file.write_bytes(b"claimed after traversal") + async with factory.begin() as session: + model = await _seed_root(session, root) + root_id = model.id + plan = prepare_removal(file, root_id=root_id, root_path=root) + injected = False + + async def reserve_then_lock(session): + nonlocal injected + if not injected: + injected = True + async with factory.begin() as owner: + await record_removal(owner, plan) + await lock_file_mutation_admission(session) + + monkeypatch.setattr(cleanup, "lock_file_mutation_admission", reserve_then_lock) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert injected and file.exists() and result.retained_entries > 0 + + +async def test_cancel_joins_unlink_before_releasing_admission( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + trash.mkdir() + (trash / "old.cbz").write_bytes(b"old") + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + real = cleanup.remove_entry + + def paused(entry): + entered.set() + assert release.wait(10) + try: + return real(entry) + finally: + finished.set() + + monkeypatch.setattr(cleanup, "remove_entry", paused) + async with factory() as session: + task = asyncio.create_task(cleanup_trash(session, trash)) + assert await asyncio.to_thread(entered.wait, 5) + task.cancel() + + async def contender(): + async with factory.begin() as other: + await lock_file_mutation_admission(other) + assert finished.is_set(), "Cancellation released the lock while unlink was running" + + other = asyncio.create_task(contender()) + try: + await asyncio.sleep(0.05) + assert not task.done() and not other.done() + task.cancel() + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + await asyncio.wait_for(other, 5) + assert finished.is_set() and not list(trash.iterdir()) + + +async def test_changed_entry_and_symlink_parent_are_not_followed( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + nested = trash / "nested" + nested.mkdir(parents=True) + (nested / "comic.cbz").write_bytes(b"trash") + outside = tmp_path / "outside" + outside.mkdir() + victim = outside / "comic.cbz" + victim.write_bytes(b"not trash") + real = cleanup.remove_entry + swapped = False + + def swap(entry): + nonlocal swapped + if not swapped: + swapped = True + nested.rename(tmp_path / "retained-original") + nested.symlink_to(outside, target_is_directory=True) + return real(entry) + + monkeypatch.setattr(cleanup, "remove_entry", swap) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert victim.read_bytes() == b"not trash" + assert (tmp_path / "retained-original" / "comic.cbz").exists() + assert result.retained_entries >= 1 + + +async def test_unknown_active_evidence_fails_closed(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + trash.mkdir() + file = trash / "old.cbz" + file.write_bytes(b"keep while recovery evidence is unknown") + async with factory.begin() as session: + session.add(LibraryRemoval(operation_id=str(uuid4()), plan_json="{}")) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert file.exists() and result.retained_entries == 1 + + +async def test_completed_trash_expires_from_receipt_and_preserves_journal( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=True) as plan: + for child in plan.stage.iterdir(): + os.utime(child, (1, 1)) + async with factory() as session: + await complete_removal(session, plan.operation_id) + async with factory.begin() as session: + row = await session.scalar(select(LibraryRemoval)) + import json + + proof = json.loads(row.cleanup_json) + proof["trash_mtime_ns"] = 1 + row.cleanup_json = json.dumps(proof) + async with factory() as session: + result = await cleanup_trash(session, plan.trash_path.parent, retention_days=30) + assert not plan.trash_path.exists() and result.deleted_entries == 3 + async with factory() as session: + assert await session.scalar(select(LibraryRemoval.state)) == "complete" + + +async def test_open_transaction_is_not_committed_by_cleanup(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with factory() as session: + session.add(LibraryRemoval(operation_id=str(uuid4()), plan_json="{}")) + await session.flush() + with pytest.raises(ValidationError, match="idle session"): + await cleanup_trash(session, tmp_path / "trash") + await session.rollback() + async with factory() as session: + assert await session.scalar(select(LibraryRemoval.id)) is None + + +async def test_nested_library_root_is_not_disposable_trash(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + trash = tmp_path.resolve() / "configured-trash" + library = trash / "actual-library" + library.mkdir(parents=True) + file = library / "not-imported-yet.cbz" + file.write_bytes(b"live library data") + async with factory.begin() as session: + await _seed_root(session, library) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert file.exists(), "A nested library root must not become disposable trash" + assert result.retained_entries >= 1 + + +async def test_directory_discovery_does_not_hold_database_writer( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + trash.mkdir() + (trash / "old.cbz").write_bytes(b"old") + entered, release = threading.Event(), threading.Event() + real = cleanup.walk_trash + + def paused(root): + entered.set() + assert release.wait(10) + yield from real(root) + + monkeypatch.setattr(cleanup, "walk_trash", paused) + async with factory() as session: + task = asyncio.create_task(cleanup_trash(session, trash)) + assert await asyncio.to_thread(entered.wait, 5) + try: + + async def write(): + async with factory.begin() as other: + await lock_file_mutation_admission(other) + + await asyncio.wait_for(write(), 2) + finally: + release.set() + result = await asyncio.wait_for(task, 5) + assert result.deleted_entries == 1 + + +async def test_entry_discovery_is_bounded_and_trash_symlinks_do_not_follow_targets( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + trash = tmp_path / "trash" + trash.mkdir() + for number in range(140): + (trash / f"{number}.cbz").write_bytes(b"old") + external = tmp_path / "outside.cbz" + external.write_bytes(b"keep") + (trash / "link.cbz").symlink_to(external) + walked = 0 + real_walk, real_remove = cleanup.walk_trash, cleanup.remove_entry + + def counted(root): + nonlocal walked + for entry in real_walk(root): + walked += 1 + yield entry + + removed = 0 + + def counted_remove(entry): + nonlocal removed + assert walked - removed <= 64, "Do not materialize the whole trash listing" + result = real_remove(entry) + removed += int(result) + return result + + monkeypatch.setattr(cleanup, "walk_trash", counted) + monkeypatch.setattr(cleanup, "remove_entry", counted_remove) + async with factory() as session: + result = await cleanup_trash(session, trash) + assert result.deleted_entries == 141 + assert external.read_bytes() == b"keep" diff --git a/tests/integration/metadata_identity/test_library_trash_migration.py b/tests/integration/metadata_identity/test_library_trash_migration.py new file mode 100644 index 00000000..76f9b439 --- /dev/null +++ b/tests/integration/metadata_identity/test_library_trash_migration.py @@ -0,0 +1,47 @@ +"""Existing trash receipts remain indexed across upgrades without touching files.""" + +import importlib.util +from pathlib import Path + +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, select + +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services.library_removal import trash_path_key +from pullbox.services.library_removal_cleanup import complete_removal +from tests.integration.metadata_identity.test_library_removal_cleanup import detached + + +def revision(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py" + ) + spec = importlib.util.spec_from_file_location("trash_index_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_upgrade_backfills_completed_receipt_without_changing_evidence( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True, folder=True) as plan: + async with factory() as session: + await complete_removal(session, plan.operation_id) + before = await session.scalar(select(LibraryRemoval.cleanup_json)) + async with engine.begin() as connection: + await connection.run_sync(lambda conn: revision(conn).downgrade()) + columns = await connection.run_sync( + lambda conn: inspect(conn).get_columns("library_removals") + ) + assert "trash_path_key" not in {item["name"] for item in columns} + await connection.run_sync(lambda conn: revision(conn).upgrade()) + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert row.trash_path_key == trash_path_key(plan.trash_path) + assert row.cleanup_json == before and row.state == "complete" and not row.active + assert (plan.trash_path / "issue.cbz").read_bytes() == b"original comic" diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index e4ea4872..08483d34 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["d1x2y3z4a567"] + assert script.get_heads() == ["e2y3z4a5b678"] + assert script.get_revision("e2y3z4a5b678").down_revision == "d1x2y3z4a567" assert script.get_revision("d1x2y3z4a567").down_revision == "c0w1x2y3z456" assert script.get_revision("c0w1x2y3z456").down_revision == "b9v0w1x2y345" assert script.get_revision("b9v0w1x2y345").down_revision == "a8u9v0w1x234" diff --git a/tests/unit/test_app_lifespan.py b/tests/unit/test_app_lifespan.py index 1c3fdcf5..5b8279f2 100644 --- a/tests/unit/test_app_lifespan.py +++ b/tests/unit/test_app_lifespan.py @@ -269,8 +269,8 @@ async def start_airdcpp_registry(_factory: object, *, enabled: bool) -> object | lambda *_args: asyncio.sleep(0), ) monkeypatch.setattr( - "pullbox.utilities.settings.cleanup_utility_trash_retention", - lambda *_args: None, + "pullbox.services.library_trash_cleanup.cleanup_trash", + lambda *_args, **_kwargs: asyncio.sleep(0), ) monkeypatch.setattr( "pullbox.utilities.settings.resolve_utility_directory", diff --git a/tests/utilities/test_router_branches.py b/tests/utilities/test_router_branches.py index b60228e6..447ccb72 100644 --- a/tests/utilities/test_router_branches.py +++ b/tests/utilities/test_router_branches.py @@ -242,12 +242,38 @@ async def test_empty_trash_uses_resolved_context( "_resolve_utility_trash_context", AsyncMock(return_value=(tmp_path / ".trash", 30)), ) - monkeypatch.setattr(utilities_router, "empty_utility_trash", lambda _path: 3) + from pullbox.services.library_trash_cleanup import TrashCleanupResult + + cleanup = AsyncMock(return_value=TrashCleanupResult(deleted_entries=3)) + monkeypatch.setattr(utilities_router, "cleanup_trash", cleanup, raising=False) response = await utilities_router.empty_trash(_user(), db_session) assert response == {"message": "Trash emptied.", "deleted_entries": 3} assert (tmp_path / ".trash").is_dir() + cleanup.assert_awaited_once_with(db_session, tmp_path / ".trash") + + +@pytest.mark.asyncio +async def test_empty_trash_reports_retained_recovery_files(db_session, tmp_path, monkeypatch): + from pullbox.services.library_trash_cleanup import TrashCleanupResult + + monkeypatch.setattr(utilities_router, "ensure_no_active_import_file_mutation", AsyncMock()) + monkeypatch.setattr( + utilities_router, + "_resolve_utility_trash_context", + AsyncMock(return_value=(tmp_path / ".trash", 30)), + ) + monkeypatch.setattr( + utilities_router, + "cleanup_trash", + AsyncMock(return_value=TrashCleanupResult(deleted_entries=2, retained_entries=3)), + raising=False, + ) + response = await utilities_router.empty_trash(_user(), db_session) + assert response["deleted_entries"] == 2 + assert response["retained_entries"] == 3 + assert "kept" in response["message"].lower() @pytest.mark.asyncio diff --git a/tests/utilities/test_utility_settings.py b/tests/utilities/test_utility_settings.py index 5653d1df..a580d845 100644 --- a/tests/utilities/test_utility_settings.py +++ b/tests/utilities/test_utility_settings.py @@ -546,11 +546,12 @@ def test_move_to_trash_deduplicates_when_destination_exists( assert existing.exists() assert not source.exists() - def test_cleanup_retention_deletes_old_files_and_prunes_empty_directories( + async def test_cleanup_retention_deletes_old_files_and_prunes_empty_directories( self, tmp_path: Path, + db_session: AsyncSession, ) -> None: - from pullbox.utilities.settings import cleanup_utility_trash_retention + from pullbox.services.library_trash_cleanup import cleanup_trash trash_dir = tmp_path / ".trash" nested_dir = trash_dir / "nested" @@ -563,19 +564,21 @@ def test_cleanup_retention_deletes_old_files_and_prunes_empty_directories( old_timestamp = (datetime.now(tz=UTC) - timedelta(days=45)).timestamp() os.utime(old_file, (old_timestamp, old_timestamp)) - deleted = cleanup_utility_trash_retention(trash_dir, retention_days=30) + await db_session.commit() + deleted = (await cleanup_trash(db_session, trash_dir, retention_days=30)).deleted_entries assert deleted == 2 assert not old_file.exists() assert new_file.exists() assert not nested_dir.exists() - def test_cleanup_retention_keeps_freshly_trashed_old_file( + async def test_cleanup_retention_keeps_freshly_trashed_old_file( self, tmp_path: Path, + db_session: AsyncSession, ) -> None: + from pullbox.services.library_trash_cleanup import cleanup_trash from pullbox.utilities.settings import ( - cleanup_utility_trash_retention, move_file_to_utility_trash, ) @@ -593,13 +596,16 @@ def test_cleanup_retention_keeps_freshly_trashed_old_file( relative_path=Path("Series/Issue 001.cbz"), ) - deleted = cleanup_utility_trash_retention(trash_dir, retention_days=30) + await db_session.commit() + deleted = (await cleanup_trash(db_session, trash_dir, retention_days=30)).deleted_entries assert deleted == 0 assert moved.exists() - def test_empty_trash_removes_all_contents_but_preserves_root(self, tmp_path: Path) -> None: - from pullbox.utilities.settings import empty_utility_trash + async def test_empty_trash_removes_all_contents_but_preserves_root( + self, tmp_path: Path, db_session: AsyncSession + ) -> None: + from pullbox.services.library_trash_cleanup import cleanup_trash trash_dir = tmp_path / ".trash" nested_dir = trash_dir / "nested" @@ -607,7 +613,8 @@ def test_empty_trash_removes_all_contents_but_preserves_root(self, tmp_path: Pat (nested_dir / "a.cbz").write_text("a") (trash_dir / "b.cb7").write_text("b") - deleted = empty_utility_trash(trash_dir) + await db_session.commit() + deleted = (await cleanup_trash(db_session, trash_dir)).deleted_entries assert deleted == 3 assert trash_dir.exists() From fb600d779e6cdb8bd8577e99dcb457cd5ec462a0 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 13:40:44 -0700 Subject: [PATCH 79/97] fix(library): finish terminal removal workspace cleanup --- ...a5b6c789_mark_removal_workspace_cleanup.py | 27 ++ docs/development/ARCHITECTURE_OVERVIEW.md | 11 +- docs/development/DATABASE_STANDARDS.md | 9 + src/pullbox/models/library_removal.py | 4 +- src/pullbox/services/library_removal.py | 7 + .../services/library_removal_cleanup.py | 9 + .../services/library_removal_workspaces.py | 168 +++++++++ .../test_library_removal_workspaces.py | 357 ++++++++++++++++++ tests/integration/test_migrations.py | 3 +- 9 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py create mode 100644 src/pullbox/services/library_removal_workspaces.py create mode 100644 tests/integration/metadata_identity/test_library_removal_workspaces.py diff --git a/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py b/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py new file mode 100644 index 00000000..e4f3d24f --- /dev/null +++ b/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py @@ -0,0 +1,27 @@ +"""Track terminal removal workspace cleanup without discarding journal evidence.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "f3z4a5b6c789" +down_revision = "e2y3z4a5b678" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "library_removals", + sa.Column("workspace_cleaned", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index( + "ix_library_removal_workspace_pending", + "library_removals", + ["workspace_cleaned", "active", "id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_library_removal_workspace_pending", table_name="library_removals") + op.drop_column("library_removals", "workspace_cleaned") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index e12037ed..12e5d735 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -649,7 +649,16 @@ off-thread in bounded pages without a database transaction. Completed removal receipts protect a newly trashed tree for its full retention window even when child files have old modification times. The settings surface reports retained entries instead of claiming that protected trash was emptied. Public deletion -owner integration and terminal private-workspace cleanup remain separate work. +owner integration remains separate work. + +`library_removal_workspaces.py` prunes only proven empty workspaces after a +committed completion or restored abandonment. It keeps original journal receipts, +checks directory identity through non-following descriptors and refuses live +cleanup locks, unknown contents or replaced directories. Startup retries terminal +workspace cleanup in bounded pages, but never resumes payload deletion. An indexed +completion marker prevents repeated filesystem inspection of cleaned workspaces. +Cancellation joins the short worker before releasing mutation admission. No public +series/browser/bulk deletion behavior is activated by this housekeeping boundary. The existing import/cache consumers have not yet migrated to this registry. Metron has token settings, bounded transport and diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 6330dab4..e02579be 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -620,6 +620,15 @@ without replacing an existing destination before removing its private source. Cancellation joins workers and leaves retryable proof. Unknown replacements are preserved. The old public source path is never used for cleanup. +`workspace_cleaned` records proven terminal staging-directory cleanup, separately +from payload disposition and retained cleanup evidence. Its composite index serves +bounded startup recovery; completed markers avoid repeated filesystem scans. +Upgrade defaults existing records to unverified without touching files. Downgrade +drops only the marker/index and preserves all authorization and cleanup receipts. +Only inactive complete/abandoned records may be pruned. Descriptor-relative empty +directory removal and lock checks run under short admission; payloads, replacements +and unknown contents are never recursively removed by terminal housekeeping. + These are owner-integration primitives, not yet the public delete implementation. Series/browser/bulk deletion must adopt the complete lifecycle before paired background writing is enabled. POSIX file locking is implemented; diff --git a/src/pullbox/models/library_removal.py b/src/pullbox/models/library_removal.py index fad44695..80d688ac 100644 --- a/src/pullbox/models/library_removal.py +++ b/src/pullbox/models/library_removal.py @@ -1,6 +1,6 @@ """Retained removal authorization and private staging evidence.""" -from sqlalchemy import Boolean, CheckConstraint, String, Text +from sqlalchemy import Boolean, CheckConstraint, Index, String, Text, false from sqlalchemy.orm import Mapped, mapped_column from pullbox.models.base import Base, IdentityMixin, TimestampMixin @@ -9,6 +9,7 @@ class LibraryRemoval(Base, IdentityMixin, TimestampMixin): __tablename__ = "library_removals" __table_args__ = ( + Index("ix_library_removal_workspace_pending", "workspace_cleaned", "active", "id"), CheckConstraint( "state IN ('intended','detached','complete','abandoned','review')", name="ck_library_removal_state", @@ -28,3 +29,4 @@ class LibraryRemoval(Base, IdentityMixin, TimestampMixin): plan_json: Mapped[str] = mapped_column(Text) cleanup_json: Mapped[str | None] = mapped_column(Text, nullable=True) trash_path_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + workspace_cleaned: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) diff --git a/src/pullbox/services/library_removal.py b/src/pullbox/services/library_removal.py index decca798..c7891fc6 100644 --- a/src/pullbox/services/library_removal.py +++ b/src/pullbox/services/library_removal.py @@ -140,6 +140,9 @@ async def recover_library_removals(session: AsyncSession) -> int: ).all() await session.commit() if not rows: + from pullbox.services.library_removal_workspaces import prune_terminal_removals + + await prune_terminal_removals(session) return restored for row_id, operation in rows: last_id = row_id @@ -327,6 +330,10 @@ def restore() -> bool: row.state, row.active = ("abandoned", False) if restored else ("review", True) state = row.state await finish_short_mutation(asyncio.create_task(session.commit())) + if restored: + from pullbox.services.library_removal_workspaces import prune_terminal_removal + + await prune_terminal_removal(session, operation_id) return state diff --git a/src/pullbox/services/library_removal_cleanup.py b/src/pullbox/services/library_removal_cleanup.py index 227e06a5..fc365f83 100644 --- a/src/pullbox/services/library_removal_cleanup.py +++ b/src/pullbox/services/library_removal_cleanup.py @@ -211,6 +211,15 @@ def publish_and_check(stop: Event) -> None: async def complete_removal(session: AsyncSession, operation_id: UUID) -> str: + """Finish committed work, then prune only its proven terminal workspaces.""" + from pullbox.services.library_removal_workspaces import prune_terminal_removal + + state = await _complete_removal(session, operation_id) + await prune_terminal_removal(session, operation_id) + return state + + +async def _complete_removal(session: AsyncSession, operation_id: UUID) -> str: """Own clean-session transactions; caller must commit detachment first. Slow copying, verification and recursive cleanup run without a DB transaction. diff --git a/src/pullbox/services/library_removal_workspaces.py b/src/pullbox/services/library_removal_workspaces.py new file mode 100644 index 00000000..8bd66abd --- /dev/null +++ b/src/pullbox/services/library_removal_workspaces.py @@ -0,0 +1,168 @@ +"""Prune proven empty terminal workspaces, never payloads or public paths.""" + +import asyncio +import os +import stat +from contextlib import ExitStack +from itertools import islice +from uuid import UUID + +import structlog +from sqlalchemy import case, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, +) +from pullbox.services.library_removal import RemovalPlan, decode_removal +from pullbox.services.library_trash_files import _open_checked + +logger = structlog.get_logger(__name__) + + +def _identity(info: os.stat_result) -> tuple[int, int, int]: + return info.st_dev, info.st_ino, info.st_mode + + +def prune_workspaces(plan: RemovalPlan) -> None: + """Bounded descriptor-relative rmdir; refuse unknown contents and live locks.""" + if os.name != "posix": + raise ValidationError("Safe workspace cleanup requires filesystem lock support.") + import fcntl + + paths = [plan.stage.parent] + if plan.trash_stage: + paths.append(plan.trash_stage.parent) + expected = {path: (device, inode, mode) for path, device, inode, mode in plan.directories} + with ExitStack() as stack: + opened = [] + for path in paths: + parent = _open_checked(path.parent, plan.directories) + stack.callback(os.close, parent) + try: + info = os.stat(path.name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + continue + if _identity(info) != expected.get(path) or stat.S_IMODE(info.st_mode) != 0o700: + raise ValidationError("Removal workspace changed; it was left untouched.") + fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + stack.callback(os.close, fd) + if _identity(os.fstat(fd)) != _identity(info): + raise ValidationError("Removal workspace changed while opening it.") + with os.scandir(fd) as entries: + names = [entry.name for entry in islice(entries, 2)] + lock = None + if names: + if path != plan.stage.parent or names != ["cleanup.lock"]: + raise ValidationError( + "Removal workspace contains unproven files; kept for review." + ) + lock = os.open("cleanup.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=fd) + stack.callback(os.close, lock) + lock_info = os.fstat(lock) + if ( + not stat.S_ISREG(lock_info.st_mode) + or stat.S_IMODE(lock_info.st_mode) != 0o600 + or lock_info.st_size != 0 + or lock_info.st_nlink != 1 + ): + raise ValidationError("Removal workspace lock changed; kept for review.") + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + opened.append((path, parent, fd, info, lock)) + # Inspect both workspaces before pruning either. rmdir never removes contents. + for path, parent, fd, info, lock in reversed(opened): + if _identity(os.stat(path.name, dir_fd=parent, follow_symlinks=False)) != _identity( + info + ): + raise ValidationError("Removal workspace changed before cleanup.") + if lock is not None: + actual = os.stat("cleanup.lock", dir_fd=fd, follow_symlinks=False) + held = os.fstat(lock) + if _identity(actual) != _identity(held) or actual.st_size or actual.st_nlink != 1: + raise ValidationError("Removal workspace lock changed before cleanup.") + os.unlink("cleanup.lock", dir_fd=fd) + os.fsync(fd) + os.rmdir(path.name, dir_fd=parent) + os.fsync(parent) + + +async def prune_terminal_removal(session: AsyncSession, operation_id: UUID) -> bool: + """Own an idle session; cleanup failure does not undo a completed removal.""" + if session.in_transaction() or session.new or session.dirty or session.deleted: + raise ValidationError("Workspace cleanup requires an idle, committed session.") + try: + async with session.begin(): + await lock_file_mutation_admission(session) + row = ( + await session.execute( + select( + LibraryRemoval.id, + LibraryRemoval.state, + LibraryRemoval.active, + LibraryRemoval.workspace_cleaned, + case( + ( + func.length(LibraryRemoval.plan_json) <= 65536, + LibraryRemoval.plan_json, + ), + else_=None, + ), + ) + .where(LibraryRemoval.operation_id == str(operation_id)) + .with_for_update() + ) + ).one_or_none() + if row is None or row.active or row.state not in {"complete", "abandoned"}: + return False + if row.workspace_cleaned: + return True + plan = decode_removal(row[4], operation_id=str(operation_id)) + await finish_short_mutation( + asyncio.create_task(asyncio.to_thread(prune_workspaces, plan)) + ) + await session.execute( + update(LibraryRemoval) + .where(LibraryRemoval.id == row.id) + .values(workspace_cleaned=True) + ) + return True + except (OSError, ValueError, TypeError, ValidationError): + logger.warning( + "library_removal_workspace_retained", operation_id=str(operation_id), exc_info=True + ) + return False + + +async def prune_terminal_removals(session: AsyncSession) -> None: + """Bounded startup repair; successful cleanup is not rescanned next startup.""" + if session.in_transaction() or session.new or session.dirty or session.deleted: + raise ValidationError("Workspace recovery requires an idle, committed session.") + last_id = 0 + while True: + rows = ( + await session.execute( + select(LibraryRemoval.id, LibraryRemoval.operation_id) + .where( + LibraryRemoval.id > last_id, + LibraryRemoval.workspace_cleaned.is_(False), + LibraryRemoval.active.is_(False), + LibraryRemoval.state.in_(["complete", "abandoned"]), + ) + .order_by(LibraryRemoval.id) + .limit(8) + ) + ).all() + await session.commit() + if not rows: + return + for row_id, operation in rows: + last_id = row_id + try: + operation_id = UUID(operation) + except ValueError: + logger.warning("library_removal_workspace_invalid_operation", removal_id=row_id) + continue + await prune_terminal_removal(session, operation_id) diff --git a/tests/integration/metadata_identity/test_library_removal_workspaces.py b/tests/integration/metadata_identity/test_library_removal_workspaces.py new file mode 100644 index 00000000..c0cd772b --- /dev/null +++ b/tests/integration/metadata_identity/test_library_removal_workspaces.py @@ -0,0 +1,357 @@ +"""Terminal removal prunes only proven empty private workspaces.""" + +import asyncio +import importlib.util +import threading +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import event, inspect, select, update + +from pullbox.models.library_removal import LibraryRemoval +from pullbox.services import library_removal_cleanup as cleanup +from pullbox.services.library_removal import recover_library_removals +from pullbox.services.library_removal_cleanup import complete_removal +from pullbox.services.library_removal_workspaces import prune_terminal_removal +from tests.integration.metadata_identity.test_library_removal_cleanup import detached + + +@pytest.mark.parametrize("folder", [False, True]) +@pytest.mark.parametrize("mode", ["delete", "trash_rename", "trash_copy"]) +async def test_completion_removes_private_workspaces_but_keeps_receipt_and_backup( + identity_probe_db, tmp_path, monkeypatch, folder, mode +): + _, factory, _ = identity_probe_db + monkeypatch.setattr(cleanup, "_same_filesystem", lambda plan: mode == "trash_rename") + async with detached(factory, tmp_path, folder=folder, trash=mode != "delete") as plan: + async with factory() as session: + assert await complete_removal(session, plan.operation_id) == "complete" + assert not plan.stage.parent.exists(), "Completed staging must not accumulate" + if plan.trash_stage: + assert not plan.trash_stage.parent.exists() + backup = plan.trash_path / "issue.cbz" if folder else plan.trash_path + assert backup.read_bytes() == b"original comic" + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert row.plan_json == plan.model_dump_json() + assert row.cleanup_json and row.state == "complete" and not row.active + assert row.workspace_cleaned + assert await complete_removal(session, plan.operation_id) == "complete" + + +@pytest.mark.parametrize("folder", [False, True]) +async def test_restart_prunes_abandoned_workspace_after_restoring_source( + identity_probe_db, tmp_path, folder +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, folder=folder, trash=True, commit=False) as plan: + async with factory() as session: + assert await recover_library_removals(session) == 1 + file = plan.source / "issue.cbz" if folder else plan.source + assert file.read_bytes() == b"original comic" + assert not plan.stage.parent.exists() + assert not plan.trash_stage.parent.exists() + assert not plan.trash_path.exists() + + +async def complete_with_lost_response(factory, plan, monkeypatch): + original = cleanup._save + + async def lose_response(*args, **kwargs): + value = await original(*args, **kwargs) + if kwargs.get("complete"): + raise OSError("lost final commit response") + return value + + with monkeypatch.context() as patch: + patch.setattr(cleanup, "_save", lose_response) + async with factory() as session: + with pytest.raises(OSError, match="lost final"): + await complete_removal(session, plan.operation_id) + assert plan.stage.parent.exists() + + +async def test_restart_prunes_terminal_workspaces_after_lost_commit_response( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + async with factory() as session: + assert await recover_library_removals(session) == 0 + assert not plan.stage.parent.exists(), "Restart must finish terminal workspace cleanup" + assert not plan.trash_stage.parent.exists() + assert plan.trash_path.read_bytes() == b"original comic" + + +@pytest.mark.parametrize("change", ["unknown_child", "replaced", "symlink", "lock_contents"]) +async def test_terminal_cleanup_keeps_unproven_workspaces( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + workspace = plan.stage.parent + if change in {"replaced", "symlink"}: + saved = workspace.with_name("saved-workspace") + workspace.rename(saved) + if change == "symlink": + workspace.symlink_to(saved, target_is_directory=True) + else: + workspace.mkdir(mode=0o700) + evidence = workspace / ("cleanup.lock" if change == "lock_contents" else "unknown.cbz") + evidence.write_bytes(b"must remain") + async with factory() as session: + assert await recover_library_removals(session) == 0 + assert evidence.read_bytes() == b"must remain" + assert workspace.exists() + assert plan.trash_path.read_bytes() == b"original comic" + + +async def test_restart_does_not_prune_workspace_while_cleanup_worker_still_holds_claim( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + entered, release = asyncio.Event(), asyncio.Event() + original = cleanup._save + + async def pause_after_commit(*args, **kwargs): + value = await original(*args, **kwargs) + if kwargs.get("complete"): + entered.set() + await asyncio.wait_for(release.wait(), 10) + return value + + monkeypatch.setattr(cleanup, "_save", pause_after_commit) + async with factory() as session: + task = asyncio.create_task(complete_removal(session, plan.operation_id)) + await asyncio.wait_for(entered.wait(), 10) + try: + async with factory() as other: + await recover_library_removals(other) + assert plan.stage.parent.exists(), "Live cleanup still owns this workspace" + finally: + release.set() + assert await asyncio.wait_for(task, 10) == "complete" + assert not plan.stage.parent.exists() + + +async def test_restart_never_prunes_detached_payload(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + async with factory() as session: + await recover_library_removals(session) + assert plan.stage.read_bytes() == b"original comic" + assert plan.trash_stage.parent.exists() + + +@pytest.mark.parametrize("change", ["empty_replacement", "parent_replaced", "terminal_payload"]) +async def test_workspace_proof_not_just_filename_or_emptiness( + identity_probe_db, tmp_path, monkeypatch, change +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + workspace = plan.stage.parent + if change == "parent_replaced": + parent = workspace.parent + parent.rename(parent.with_name("saved-parent")) + parent.mkdir() + workspace.mkdir(mode=0o700) + elif change == "empty_replacement": + workspace.rename(workspace.with_name("saved-workspace")) + workspace.mkdir(mode=0o700) + else: + plan.stage.write_bytes(b"unproven private payload") + async with factory() as session: + await recover_library_removals(session) + row = await session.scalar(select(LibraryRemoval)) + assert not row.workspace_cleaned + assert workspace.exists() + if change == "terminal_payload": + assert plan.stage.read_bytes() == b"unproven private payload" + + +async def test_terminal_cleanup_never_commits_pending_caller_edits( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.core.exceptions import ValidationError + + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + async with factory() as session: + await session.execute(update(LibraryRemoval).values(active=True)) + with pytest.raises(ValidationError, match="idle"): + await prune_terminal_removal(session, plan.operation_id) + await session.rollback() + assert plan.stage.parent.exists() + async with factory() as session: + assert not (await session.scalar(select(LibraryRemoval))).active + + +async def test_restart_retry_after_partial_prune_and_lost_cleanup_receipt( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import library_removal_workspaces as workspaces + + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + # A prior attempt may have pruned the trash workspace before interruption. + plan.trash_stage.parent.rmdir() + real = workspaces.prune_workspaces + + def lose_acknowledgement(plan): + real(plan) + raise OSError("lost prune acknowledgement") + + with monkeypatch.context() as patch: + patch.setattr(workspaces, "prune_workspaces", lose_acknowledgement) + async with factory() as session: + assert not await prune_terminal_removal(session, plan.operation_id) + assert not plan.stage.parent.exists() + async with factory() as session: + await recover_library_removals(session) + assert (await session.scalar(select(LibraryRemoval))).workspace_cleaned + assert plan.trash_path.read_bytes() == b"original comic" + + +async def test_restart_cleanup_is_bounded_and_skips_recorded_successes( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import library_removal_workspaces as workspaces + + engine, factory, _ = identity_probe_db + plans = [] + for index in range(10): + home = tmp_path / str(index) + home.mkdir() + async with detached(factory, home) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + plans.append(plan) + selects = [] + + def capture(_conn, _cursor, statement, parameters, _context, _many): + if statement.lstrip().upper().startswith("SELECT") and "library_removals.id >" in statement: + selects.append((statement, parameters)) + + event.listen(engine.sync_engine, "before_cursor_execute", capture) + try: + async with factory() as session: + await recover_library_removals(session) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", capture) + assert len(selects) >= 3 + assert all("LIMIT" in statement and 8 in parameters for statement, parameters in selects) + assert all(not plan.stage.parent.exists() for plan in plans) + + def no_repeat(*args): + pytest.fail("Recorded terminal workspace cleanup must not rescan the filesystem") + + monkeypatch.setattr(workspaces, "prune_workspaces", no_repeat) + async with factory() as session: + await recover_library_removals(session) + + +def migration(connection): + path = ( + Path(__file__).resolve().parents[3] + / "alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py" + ) + spec = importlib.util.spec_from_file_location("removal_workspace_revision", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.op = Operations(MigrationContext.configure(connection)) + return module + + +async def test_workspace_migration_preserves_receipts_and_defaults_to_unverified( + identity_probe_db, tmp_path +): + engine, factory, _ = identity_probe_db + async with detached(factory, tmp_path, trash=True) as plan: + async with factory() as session: + await complete_removal(session, plan.operation_id) + row = await session.scalar(select(LibraryRemoval)) + before = row.plan_json, row.cleanup_json, row.state, row.active + assert row.workspace_cleaned + async with engine.begin() as connection: + await connection.run_sync(lambda conn: migration(conn).downgrade()) + columns = await connection.run_sync( + lambda conn: inspect(conn).get_columns("library_removals") + ) + assert "workspace_cleaned" not in {column["name"] for column in columns} + await connection.run_sync(lambda conn: migration(conn).upgrade()) + indexes = await connection.run_sync( + lambda conn: inspect(conn).get_indexes("library_removals") + ) + assert any( + index["column_names"] == ["workspace_cleaned", "active", "id"] for index in indexes + ) + async with factory() as session: + row = await session.scalar(select(LibraryRemoval)) + assert (row.plan_json, row.cleanup_json, row.state, row.active) == before + assert not row.workspace_cleaned + await session.commit() + await recover_library_removals(session) + assert plan.trash_path.read_bytes() == b"original comic" + + +async def test_corrupt_terminal_operation_does_not_stop_startup_recovery( + identity_probe_db, tmp_path, monkeypatch +): + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + async with factory.begin() as session: + await session.execute(update(LibraryRemoval).values(operation_id="invalid-operation")) + async with factory() as session: + assert await recover_library_removals(session) == 0 + assert plan.stage.parent.exists() + + +async def test_cancelled_workspace_prune_joins_worker_before_releasing_admission( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import library_removal_workspaces as workspaces + from pullbox.services.library_mutation_coordination import lock_file_mutation_admission + + _, factory, _ = identity_probe_db + async with detached(factory, tmp_path) as plan: + await complete_with_lost_response(factory, plan, monkeypatch) + real = workspaces.prune_workspaces + entered, release = threading.Event(), threading.Event() + + def pause(plan): + entered.set() + assert release.wait(10) + real(plan) + + monkeypatch.setattr(workspaces, "prune_workspaces", pause) + + async def competing_writer(): + async with factory.begin() as session: + await lock_file_mutation_admission(session) + + async with factory() as session: + task = asyncio.create_task(prune_terminal_removal(session, plan.operation_id)) + assert await asyncio.to_thread(entered.wait, 10) + writer = asyncio.create_task(competing_writer()) + try: + for _ in range(3): + task.cancel() + await asyncio.sleep(0.02) + assert not task.done() and not writer.done() + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 10) + await asyncio.wait_for(writer, 10) + assert not plan.stage.parent.exists() + async with factory() as session: + await recover_library_removals(session) + assert (await session.scalar(select(LibraryRemoval))).workspace_cleaned diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 08483d34..bb774951 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1873,7 +1873,8 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == ["e2y3z4a5b678"] + assert script.get_heads() == ["f3z4a5b6c789"] + assert script.get_revision("f3z4a5b6c789").down_revision == "e2y3z4a5b678" assert script.get_revision("e2y3z4a5b678").down_revision == "d1x2y3z4a567" assert script.get_revision("d1x2y3z4a567").down_revision == "c0w1x2y3z456" assert script.get_revision("c0w1x2y3z456").down_revision == "b9v0w1x2y345" From 0f76dba4e8ded147b05e2cc796d7c06dfad11242 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 17:44:25 -0700 Subject: [PATCH 80/97] feat(import): connect paired archive metadata writing and retries Use the existing import-owned publication lifecycle for managed CBZ metadata, preserving fetched provider fields and local edits. Expose failed writes in scoped Follow-up without rerunning placement or changing ownership evidence. Keep this as an undeployed integration checkpoint. Remaining overlap and initial-import acceptance gates precede paired-writer activation. Update the explicit route manifest for the two new endpoints. --- .../services/archive_metadata_binding.py | 17 +- .../services/archive_metadata_rendering.py | 2 +- .../services/import_comicinfo_enrichment.py | 110 +++- src/pullbox/services/import_file_execution.py | 20 +- .../services/import_metadata_follow_up.py | 157 +++++ src/pullbox/services/import_service.py | 12 + .../services/metadata_series_refresh_state.py | 10 +- src/pullbox/tasks/import_metadata_writing.py | 186 ++++++ src/pullbox/ui/import_follow_up.py | 7 + src/pullbox/ui/import_results_context.py | 5 + src/pullbox/ui/import_routes.py | 65 ++ .../import_metadata_write_follow_up.html | 39 ++ .../ui/templates/partials/import_results.html | 18 + tests/api/test_import_metadata_follow_up.py | 101 ++++ tests/e2e/test_import_metadata_follow_up.py | 150 +++++ .../test_import_metadata_enrichment.py | 560 ++++++++++++++++++ .../test_import_metadata_retry.py | 94 +++ tests/ui/test_ui_router_contract.py | 12 + tests/unit/test_archive_metadata_rendering.py | 27 + .../unit/test_import_comicinfo_enrichment.py | 15 +- tests/unit/test_import_file_execution.py | 14 +- 21 files changed, 1588 insertions(+), 33 deletions(-) create mode 100644 src/pullbox/services/import_metadata_follow_up.py create mode 100644 src/pullbox/tasks/import_metadata_writing.py create mode 100644 src/pullbox/ui/templates/partials/import_metadata_write_follow_up.html create mode 100644 tests/api/test_import_metadata_follow_up.py create mode 100644 tests/e2e/test_import_metadata_follow_up.py create mode 100644 tests/integration/metadata_identity/test_import_metadata_enrichment.py create mode 100644 tests/integration/metadata_identity/test_import_metadata_retry.py diff --git a/src/pullbox/services/archive_metadata_binding.py b/src/pullbox/services/archive_metadata_binding.py index b45d5330..dbf8d498 100644 --- a/src/pullbox/services/archive_metadata_binding.py +++ b/src/pullbox/services/archive_metadata_binding.py @@ -3,6 +3,7 @@ import asyncio import os import stat +from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -20,6 +21,7 @@ from pullbox.models import Issue, LibraryFile, LibraryRoot from pullbox.models.library import FileFormat, LibraryFileStorageMode from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.schemas.metadata_sources import ProviderIssueRead from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_series_refresh_state import ( @@ -99,7 +101,10 @@ async def read_archive_metadata_binding( ): raise ArchiveMetadataBindingError("issue_changed") try: - metadata = await read_series_refresh_state(session, series_id, issue_ids=(file.issue_id,)) + # Writing one verified issue does not require the entire catalog to be complete. + metadata = await read_series_refresh_state( + session, series_id, issue_ids=(file.issue_id,), allow_partial_catalog=True + ) for entity in (metadata.series, metadata.issues[0]): _require_verified(entity) if not {ref.namespace for ref in metadata.issues[0].identities} <= { @@ -244,16 +249,20 @@ def assemble_bound_archive_metadata( archive: ArchiveMetadataReconciliation, *, now: datetime, + issue_candidates: Sequence[ProviderIssueRead] = (), ) -> tuple[MetadataSnapshot, MetadataSnapshot]: """Combine DB values and local evidence without granting embedded IDs ownership.""" try: - return _assemble(binding.metadata, archive, now) + return _assemble(binding.metadata, archive, now, issue_candidates) except ValueError: raise ArchiveMetadataBindingError("metadata_requires_review") from None def _assemble( - metadata: SeriesRefreshState, archive: ArchiveMetadataReconciliation | None, now: datetime + metadata: SeriesRefreshState, + archive: ArchiveMetadataReconciliation | None, + now: datetime, + issue_candidates: Sequence[ProviderIssueRead] = (), ) -> tuple[MetadataSnapshot, MetadataSnapshot]: series, issue = metadata.series, metadata.issues[0] return ( @@ -271,7 +280,7 @@ def _assemble( assemble_metadata( MetadataEntityKind.ISSUE, issue.identities, - (), + issue_candidates, metadata.policies, now=now, current=issue.values, diff --git a/src/pullbox/services/archive_metadata_rendering.py b/src/pullbox/services/archive_metadata_rendering.py index f571ce47..07c1dc57 100644 --- a/src/pullbox/services/archive_metadata_rendering.py +++ b/src/pullbox/services/archive_metadata_rendering.py @@ -82,7 +82,7 @@ def render_archive_metadata( for current, previous in ((series, previous_series), (issue, previous_issue)): if previous is not None and ( previous.entity_kind != current.entity_kind - or set(previous.identities) != set(current.identities) + or not set(previous.identities) <= set(current.identities) ): raise ArchiveMetadataRenderError("invalid_baseline") archive = reconcile_archive_metadata(files) diff --git a/src/pullbox/services/import_comicinfo_enrichment.py b/src/pullbox/services/import_comicinfo_enrichment.py index 54948d9d..e822cae2 100644 --- a/src/pullbox/services/import_comicinfo_enrichment.py +++ b/src/pullbox/services/import_comicinfo_enrichment.py @@ -6,7 +6,7 @@ import hashlib import inspect import os -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -18,6 +18,7 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.orm import joinedload +from pullbox.core.metadata_identity import MetadataSource from pullbox.core.sqlite_lock import ( SQLITE_LOCK_RETRY_ATTEMPTS, is_sqlite_locked_error, @@ -37,19 +38,24 @@ from pullbox.services.import_comicinfo_metadata import is_retryable_provider_error from pullbox.services.import_metadata_priority import wait_for_comicinfo_turn from pullbox.services.import_metadata_progress import track_import_metadata_progress +from pullbox.services.metadata_sources import read_source_policies if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + from pullbox.providers.base import IssueMetadata + logger = structlog.get_logger(__name__) COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY = "comicinfo_enrichment" COMICVINE_BULK_BATCH_SIZE = 5_000 +COMICINFO_FILE_BATCH_SIZE = 200 ImportComicInfoBuildPayload = Callable[..., Awaitable[dict[str, Any]]] ImportComicInfoApply = Callable[[Path, dict[str, Any]], Any] ImportComicInfoLogEvent = Callable[..., Awaitable[None]] -ImportComicInfoPrefetch = Callable[[list[int]], Awaitable[Any]] +type ImportComicInfoPrefetch = Callable[[list[int]], Awaitable[dict[int, IssueMetadata]]] +type ImportMetadataWriter = Callable[[int, Mapping[int, IssueMetadata]], Awaitable[bool]] comicinfo_enrichment_tasks: set[asyncio.Task[None]] = set() _comicinfo_enrichment_semaphore: asyncio.Semaphore | None = None @@ -94,6 +100,7 @@ def schedule_import_comicinfo_enrichment( apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, + metadata_writer: ImportMetadataWriter | None = None, ) -> None: """Queue deferred issue metadata and ComicInfo rewrites after Step 4 completes.""" if session_factory is None: @@ -108,6 +115,7 @@ async def run_enrichment() -> None: apply_comicinfo=apply_comicinfo, log_event=log_event, prefetch_issue_metadata=prefetch_issue_metadata, + metadata_writer=metadata_writer, ) except Exception as exc: logger.warning( @@ -128,6 +136,7 @@ async def run_pending_import_comicinfo_enrichment( apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, + metadata_writer: ImportMetadataWriter | None = None, ) -> int: """Refresh deferred ComicInfo metadata for all completed jobs with pending rows.""" from pullbox.tasks.import_archive_recovery import recover_import_archive_publications @@ -143,6 +152,7 @@ async def run_pending_import_comicinfo_enrichment( apply_comicinfo=apply_comicinfo, log_event=log_event, prefetch_issue_metadata=prefetch_issue_metadata, + metadata_writer=metadata_writer, ) return len(pending_job_ids) @@ -155,6 +165,7 @@ async def run_import_comicinfo_enrichment( apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, + metadata_writer: ImportMetadataWriter | None = None, ) -> None: """Refresh deferred ComicInfo metadata for imported files in one import job.""" async with ( @@ -168,6 +179,7 @@ async def run_import_comicinfo_enrichment( apply_comicinfo=apply_comicinfo, log_event=log_event, prefetch_issue_metadata=prefetch_issue_metadata, + metadata_writer=metadata_writer, ) @@ -179,6 +191,7 @@ async def _run_import_comicinfo_enrichment_while_fenced( apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, + metadata_writer: ImportMetadataWriter | None = None, ) -> None: """Run one job while holding the process-local filesystem mutation fence.""" from pullbox.tasks.import_archive_recovery import recover_import_archive_publications @@ -188,24 +201,17 @@ async def _run_import_comicinfo_enrichment_while_fenced( if not await _import_job_is_completed(session_factory, job_id=job_id): return pending_ids = await _load_pending_imported_file_ids(session_factory, job_id=job_id) - if prefetch_issue_metadata is not None: - issue_cv_ids = await _load_pending_issue_cv_ids(session_factory, job_id=job_id) - if issue_cv_ids: - for start in range(0, len(issue_cv_ids), COMICVINE_BULK_BATCH_SIZE): - batch = issue_cv_ids[start : start + COMICVINE_BULK_BATCH_SIZE] - await wait_for_comicinfo_turn() - try: - await prefetch_issue_metadata(batch) - except Exception as exc: - logger.warning( - "import_comicinfo_metadata_batch_prefetch_failed", - job_id=job_id, - issue_count=len(batch), - error=str(exc), - ) - for imported_file_id in pending_ids: + async for imported_file_id, prefetched in _enrichment_inputs( + session_factory, + job_id=job_id, + pending_ids=pending_ids, + prefetch=prefetch_issue_metadata, + paired=metadata_writer is not None, + ): await wait_for_comicinfo_turn() try: + if metadata_writer is not None and await metadata_writer(imported_file_id, prefetched): + continue prepared = await _prepare_pending_imported_file_with_retry( session_factory, imported_file_id=imported_file_id, @@ -235,6 +241,10 @@ async def _run_import_comicinfo_enrichment_while_fenced( log_event=log_event, ) except Exception as exc: + if metadata_writer is not None and not await _import_job_is_completed( + session_factory, job_id=job_id + ): + return if is_retryable_provider_error(exc): logger.warning( "import_comicinfo_enrichment_deferred_for_provider", @@ -252,6 +262,50 @@ async def _run_import_comicinfo_enrichment_while_fenced( ) +async def _enrichment_inputs( + session_factory: async_sessionmaker[AsyncSession], + *, + job_id: int, + pending_ids: list[int], + prefetch: ImportComicInfoPrefetch | None, + paired: bool, +) -> AsyncIterator[tuple[int, Mapping[int, IssueMetadata]]]: + """Consume each bounded batch before fetching more full provider metadata.""" + size = min(COMICINFO_FILE_BATCH_SIZE, COMICVINE_BULK_BATCH_SIZE) + for start in range(0, len(pending_ids), size): + if not await _import_job_is_completed(session_factory, job_id=job_id): + return + file_ids = pending_ids[start : start + size] + enabled = True + if paired: + async with session_factory() as session: + enabled = any( + policy.source is MetadataSource.COMICVINE_API and policy.enabled + for policy in await read_source_policies(session) + ) + metadata: dict[int, IssueMetadata] = {} + if prefetch is not None and enabled: + provider_ids = await _load_pending_issue_cv_ids( + session_factory, job_id=job_id, imported_file_ids=file_ids + ) + if provider_ids: + await wait_for_comicinfo_turn() + try: + metadata = await prefetch(provider_ids) + except Exception as exc: + logger.warning( + "import_comicinfo_metadata_batch_prefetch_failed", + job_id=job_id, + issue_count=len(provider_ids), + error=str(exc), + ) + if paired: + # Do not report a sparse paired write as complete after a failed fetch. + return + for file_id in file_ids: + yield file_id, metadata + + async def _import_job_is_completed( session_factory: async_sessionmaker[AsyncSession], *, @@ -294,6 +348,7 @@ async def _load_pending_issue_cv_ids( session_factory: async_sessionmaker[AsyncSession], *, job_id: int, + imported_file_ids: list[int], ) -> list[int]: """Load unique provider issue IDs already recorded in pending diagnostics.""" async with session_factory() as session: @@ -301,6 +356,7 @@ async def _load_pending_issue_cv_ids( sa_select(ImportedFile.diagnostics) .join(ImportJob, ImportedFile.import_job_id == ImportJob.id) .where(ImportedFile.import_job_id == job_id) + .where(ImportedFile.id.in_(imported_file_ids)) .where(ImportJob.status == ImportJobStatus.COMPLETED) .where(ImportJob.control_request == ImportControlRequest.NONE) .where(ImportedFile.status == ImportedFileStatus.IMPORTED) @@ -613,6 +669,24 @@ async def _mark_pending_file_failed( imported_file = await session.get(ImportedFile, imported_file_id) if imported_file is None: return True + active_publication = await session.scalar( + sa_select(ArchiveMetadataPublication.id) + .where( + ArchiveMetadataPublication.active_file_id == imported_file.library_file_id, + ArchiveMetadataPublication.active_path_key.is_not(None), + ) + .limit(1) + ) + if active_publication is not None: + # Pending diagnostics are part of the immutable owner evidence. + # Changing them would prevent restart from settling publication. + logger.warning( + "import_metadata_publication_needs_recovery", + job_id=job_id, + imported_file_id=imported_file_id, + publication_id=active_publication, + ) + return False _set_comicinfo_enrichment_status( imported_file, status="failed", diff --git a/src/pullbox/services/import_file_execution.py b/src/pullbox/services/import_file_execution.py index 0148ad16..fd05adc7 100644 --- a/src/pullbox/services/import_file_execution.py +++ b/src/pullbox/services/import_file_execution.py @@ -1270,6 +1270,15 @@ async def report_current_file( effective_embedded_comicinfo = update_embedded_comicinfo_from_match and not bool( getattr(prepared, "skip_embedded_comicinfo", False) ) + defer_paired_metadata = ( + defer_comicinfo_enrichment + and effective_embedded_comicinfo + and move_to_library + and Path(prepared.registration_source).suffix.casefold() in {".cbz", ".zip"} + ) + if defer_paired_metadata: + # Copy unchanged first; the owned background job reconciles both XML files. + effective_embedded_comicinfo = False preparation_warning = getattr(prepared, "preparation_warning", None) if preparation_warning: await log_event( @@ -1424,7 +1433,7 @@ async def report_current_file( imp_file.matched_issue_id = resolved_issue.id imp_file.library_file_id = library_file.id imp_file.status = ImportedFileStatus.IMPORTED - comicinfo_enrichment_deferred = ( + comicinfo_enrichment_deferred = defer_paired_metadata or ( bool(defer_comicinfo_enrichment) and comicinfo_payload is not None and resolved_issue.comicvine_id is not None @@ -1464,7 +1473,7 @@ async def report_current_file( unit="steps", live_only=True, ) - await record_action( + placement_action = await record_action( session, current_job, phase="import", @@ -1515,6 +1524,13 @@ async def report_current_file( ), }, ) + if comicinfo_enrichment_deferred and isinstance(placement_action.id, int): + diagnostics = dict(imp_file.diagnostics) + diagnostics[_COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY] = { + **diagnostics[_COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY], + "action_id": placement_action.id, + } + imp_file.diagnostics = diagnostics await log_event( session, diff --git a/src/pullbox/services/import_metadata_follow_up.py b/src/pullbox/services/import_metadata_follow_up.py new file mode 100644 index 00000000..170d9096 --- /dev/null +++ b/src/pullbox/services/import_metadata_follow_up.py @@ -0,0 +1,157 @@ +"""Bounded follow-up and explicit retries for imported archive metadata writes.""" + +from datetime import UTC, datetime + +from sqlalchemy import ColumnElement, and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.models import LibraryFile +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportedFileStatus, + ImportJob, + ImportJobLog, + ImportJobStatus, +) +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission + +PAGE_SIZE = 25 + + +def failed_metadata_write_filter() -> ColumnElement[bool]: + return and_( + ImportedFile.status == ImportedFileStatus.IMPORTED, + ImportedFile.diagnostics["comicinfo_enrichment"]["status"].as_string() == "failed", + ) + + +async def count_failed_metadata_writes(session: AsyncSession, job_id: int) -> int: + return int( + await session.scalar( + select(func.count()) + .select_from(ImportedFile) + .where( + ImportedFile.import_job_id == job_id, + failed_metadata_write_filter(), + ) + ) + or 0 + ) + + +async def load_metadata_write_follow_up( + session: AsyncSession, + job_id: int, + *, + page: int = 1, +) -> dict[str, object]: + job = await session.get(ImportJob, job_id) + if job is None or job.archived_at is not None: + raise NotFoundError("ImportJob", job_id) + total = await count_failed_metadata_writes(session, job_id) + pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = min(max(page, 1), pages) + files = list( + await session.scalars( + select(ImportedFile) + .where( + ImportedFile.import_job_id == job_id, + failed_metadata_write_filter(), + ) + .order_by(ImportedFile.id) + .offset((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + ) + ) + return { + "job": job, + "metadata_files": files, + "metadata_total": total, + "metadata_page": page, + "metadata_pages": pages, + "metadata_retry_allowed": job.status is ImportJobStatus.COMPLETED + and job.control_request is ImportControlRequest.NONE, + } + + +async def retry_import_metadata_write( + session: AsyncSession, + job_id: int, + file_id: int, + *, + actor: str, +) -> bool: + """Queue only failed metadata work; preserve placement and publication evidence. + + The caller commits before scheduling the existing background job. All file, + identity and root checks still run in that job, never in the request's write + transaction. This does not approve new file contents or change a match. + """ + await lock_file_mutation_admission(session) + job = await session.scalar( + select(ImportJob) + .where(ImportJob.id == job_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + if job is None or job.archived_at is not None: + raise NotFoundError("ImportJob", job_id) + if ( + job.status is not ImportJobStatus.COMPLETED + or job.control_request is not ImportControlRequest.NONE + ): + raise ValidationError("This import is no longer ready for a metadata retry.") + file = await session.scalar( + select(ImportedFile) + .where( + ImportedFile.id == file_id, + ImportedFile.import_job_id == job_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if file is None: + raise NotFoundError("ImportedFile", file_id) + details = file.diagnostics.get("comicinfo_enrichment", {}) + if not isinstance(details, dict) or file.status is not ImportedFileStatus.IMPORTED: + raise ValidationError("This file has no imported metadata work to retry.") + if details.get("status") in {"pending", "complete"}: + return False + if details.get("status") != "failed": + raise ValidationError("This file has no failed metadata write to retry.") + if file.library_file_id is None or await session.get(LibraryFile, file.library_file_id) is None: + raise ValidationError("The library file is no longer registered. Rescan its series first.") + active = await session.scalar( + select(ArchiveMetadataPublication.id) + .where( + ArchiveMetadataPublication.active_file_id == file.library_file_id, + ArchiveMetadataPublication.active_path_key.is_not(None), + ) + .limit(1) + ) + if active is not None: + raise ValidationError( + "An archive write still needs recovery. Retry after recovery completes." + ) + file.diagnostics = { + **file.diagnostics, + "comicinfo_enrichment": { + **details, + "status": "pending", + "retry_requested_at": datetime.now(UTC).isoformat(), + }, + } + session.add( + ImportJobLog( + import_job_id=job_id, + level="INFO", + event="import_archive_metadata_retry_requested", + message=f"Archive metadata retry requested for {file.file_name}", + data={"imported_file_id": file_id, "actor": actor}, + ) + ) + await session.flush() + return True diff --git a/src/pullbox/services/import_service.py b/src/pullbox/services/import_service.py index edea9580..35fdf456 100644 --- a/src/pullbox/services/import_service.py +++ b/src/pullbox/services/import_service.py @@ -762,6 +762,12 @@ def schedule_comicinfo_enrichment( job_id: int, ) -> None: """Queue deferred ComicInfo rewrites after the import transaction commits.""" + from functools import partial + + from pullbox.tasks.import_metadata_writing import write_imported_archive_metadata + + if session_factory is None: + return schedule_import_comicinfo_enrichment( session_factory, job_id=job_id, @@ -769,6 +775,7 @@ def schedule_comicinfo_enrichment( apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, + metadata_writer=partial(write_imported_archive_metadata, session_factory), ) def schedule_story_arc_sync(self) -> None: @@ -782,12 +789,17 @@ async def recover_pending_comicinfo_enrichment( session_factory: async_sessionmaker[AsyncSession], ) -> int: """Resume deferred ComicInfo rewrites left pending after a restart.""" + from functools import partial + + from pullbox.tasks.import_metadata_writing import write_imported_archive_metadata + return await run_pending_import_comicinfo_enrichment( session_factory, build_comicinfo_payload=self._build_comicinfo_payload_for_issue, apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, + metadata_writer=partial(write_imported_archive_metadata, session_factory), ) async def recover_pending_catalog_hydration( diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index c56286d5..e802873e 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -99,9 +99,15 @@ def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot async def read_series_refresh_state( - session: AsyncSession, series_id: int, *, issue_ids: tuple[int, ...] | None = None + session: AsyncSession, + series_id: int, + *, + issue_ids: tuple[int, ...] | None = None, + allow_partial_catalog: bool = False, ) -> SeriesRefreshState: """Capture a catalog or a bounded exact subset using the same value mapping.""" + if allow_partial_catalog and issue_ids is None: + raise ValueError("Partial catalog reads require an exact bounded issue selection.") if issue_ids is not None and ( not issue_ids or len(issue_ids) > 200 @@ -133,7 +139,7 @@ async def read_series_refresh_state( if row is None: raise NotFoundError("Series", series_id) series, publisher, revision, payload = row - if series.issue_catalog_state is IssueCatalogState.HYDRATING: + if series.issue_catalog_state is IssueCatalogState.HYDRATING and not allow_partial_catalog: raise ValueError("Initial metadata sync is already in progress.") baseline = _baseline(payload, MetadataEntityKind.SERIES) values = baseline.values.model_dump() if baseline else {} diff --git a/src/pullbox/tasks/import_metadata_writing.py b/src/pullbox/tasks/import_metadata_writing.py new file mode 100644 index 00000000..fdcb5406 --- /dev/null +++ b/src/pullbox/tasks/import_metadata_writing.py @@ -0,0 +1,186 @@ +"""Run import-owned paired archive writing using the existing publication lifecycle.""" + +import asyncio +from collections.abc import Mapping +from datetime import UTC, datetime +from uuid import uuid4 +from zipfile import ZipFile + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from pullbox.core.archive_metadata import MAX_METADATA_BYTES, read_archive_metadata +from pullbox.core.file_safety import ( + get_archive_size_limit_bytes, + is_dangerous_file_blocking_enabled, + is_resource_safety_exception_allowed, +) +from pullbox.core.metadata_identity import ExternalIdentityRef, IdentityNamespace, MetadataSource +from pullbox.models import LibraryFile +from pullbox.models.import_job import ( + ImportControlRequest, + ImportedFile, + ImportJob, + ImportJobAction, + ImportJobStatus, +) +from pullbox.models.library import FileFormat +from pullbox.providers.base import IssueMetadata +from pullbox.providers.metadata.comicvine_normalization import issue as normalize_comicvine_issue +from pullbox.schemas.metadata_sources import MetadataDomain, ProviderIssueRead +from pullbox.services.archive_metadata_binding import ( + ArchiveMetadataBinding, + assemble_bound_archive_metadata, + inspect_archive_metadata_target, + read_archive_metadata_binding, +) +from pullbox.services.archive_metadata_finalization import finalize_archive_publication +from pullbox.services.archive_metadata_publication import ( + ArchivePublicationError, + inspect_archive_publication, + prepare_archive_publication, + publish_archive_publication, + record_archive_publication, +) +from pullbox.services.archive_metadata_reconciliation import ( + ArchiveMetadataReconciliation, + reconcile_archive_metadata, +) +from pullbox.services.catalog.reader import CatalogIssueMetadata +from pullbox.services.import_archive_publication import bind_import_archive_publication +from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible + + +def _primary_identity( + binding: ArchiveMetadataBinding, archive: ArchiveMetadataReconciliation +) -> ExternalIdentityRef | None: + identities = binding.metadata.issues[0].identities + if archive.metroninfo.metron is not None: + for item in archive.metroninfo.metron.identities: + if item.primary and item.evidence.identity in identities: + return item.evidence.identity + ranks: dict[IdentityNamespace, int] = {} + for policy in binding.metadata.policies: + rank = policy.domain_priorities.get(MetadataDomain.CORE, policy.priority) + namespace = policy.identity_namespace + ranks[namespace] = min(rank, ranks.get(namespace, rank)) + return min( + identities, + key=lambda ref: (ranks.get(ref.namespace, 1001), ref.namespace.value, ref.external_id), + default=None, + ) + + +async def write_imported_archive_metadata( + factory: async_sessionmaker[AsyncSession], + imported_file_id: int, + prefetched: Mapping[int, IssueMetadata], +) -> bool: + """Handle a queued CBZ, or leave other formats to their existing writer. + + This task owns its short sessions. A rejected CBZ never falls back to the + single-document writer. Reference-only and unverified targets fail closed. + """ + async with factory() as session: + file = await session.get(ImportedFile, imported_file_id) + if file is None or file.library_file_id is None: + raise ArchivePublicationError("import_owner_missing") + library = await session.get(LibraryFile, file.library_file_id) + if library is not None and library.file_format is not FileFormat.CBZ: + return False + binding = await read_archive_metadata_binding( + session, file.library_file_id, expected_issue_id=file.matched_issue_id + ) + job_id = file.import_job_id + details = file.diagnostics.get("comicinfo_enrichment", {}) + action_id = details.get("action_id") if isinstance(details, dict) else None + actions = ( + [action_id] + if type(action_id) is int and action_id > 0 + else list( + await session.scalars( + select(ImportJobAction.id) + .where( + ImportJobAction.import_job_id == job_id, + ImportJobAction.action_type == "library_file_registered", + ImportJobAction.payload["imported_file_id"].as_integer() == file.id, + ) + .limit(2) + ) + ) + ) + if len(actions) != 1: + raise ArchivePublicationError("import_owner_missing_or_ambiguous") + limit = await get_archive_size_limit_bytes(session) + block_dangerous = await is_dangerous_file_blocking_enabled(session) + allowed_resource_exception = is_resource_safety_exception_allowed(file.diagnostics) + + async def check_control() -> None: + async with factory() as session: + job = await session.get(ImportJob, job_id) + if ( + job is None + or job.status is not ImportJobStatus.COMPLETED + or job.control_request is not ImportControlRequest.NONE + ): + raise asyncio.CancelledError + + await check_control() + target = await inspect_archive_metadata_target(binding) + files = await asyncio.to_thread( + read_archive_metadata, target.path, "cbz", max_solid_scan_bytes=MAX_METADATA_BYTES + ) + archive = reconcile_archive_metadata(files) + cv_id = binding.metadata.issues[0].comicvine_id + cached = prefetched.get(cv_id) if cv_id is not None else None + candidates: tuple[ProviderIssueRead, ...] = () + if cached is not None: + source = ( + MetadataSource.COMICVINE_LOCAL + if isinstance(cached, CatalogIssueMetadata) + else MetadataSource.COMICVINE_API + ) + if any(policy.source is source and policy.enabled for policy in binding.metadata.policies): + candidates = (normalize_comicvine_issue(source, cached),) + series, issue = assemble_bound_archive_metadata( + binding, archive, now=datetime.now(UTC), issue_candidates=candidates + ) + if allowed_resource_exception: + + def approved_budget() -> int: + with ZipFile(target.path) as source: + # The exception applies only to this captured file, not global policy. + return ( + sum(member.file_size for member in source.infolist()) + 2 * MAX_METADATA_BYTES + ) + + limit = max(limit, await asyncio.to_thread(approved_budget)) + await asyncio.to_thread(target.check_unchanged) + + async with stage_cbz_metadata_interruptible( + target.path, + target.path.parent, + series, + issue, + max_uncompressed_bytes=limit, + block_dangerous=block_dangerous, + primary_identity=_primary_identity(binding, archive), + previous_series=binding.metadata.series.baseline, + previous_issue=binding.metadata.issues[0].baseline, + cancellation_check=check_control, + ) as staged: + plan = await prepare_archive_publication(target, staged, series, issue) + async with factory() as session: + plan = await bind_import_archive_publication( + session, plan, imported_file_id=imported_file_id, action_id=actions[0] + ) + operation = uuid4() + async with factory.begin() as session: + await record_archive_publication(session, plan, operation) + await check_control() + async with factory.begin() as session: + receipt = await publish_archive_publication(session, operation) + inspection = await inspect_archive_publication(receipt) + async with factory.begin() as session: + await finalize_archive_publication(session, receipt, inspection) + return True diff --git a/src/pullbox/ui/import_follow_up.py b/src/pullbox/ui/import_follow_up.py index 871ed729..3d0a050b 100644 --- a/src/pullbox/ui/import_follow_up.py +++ b/src/pullbox/ui/import_follow_up.py @@ -18,6 +18,7 @@ from pullbox.models.series import IssueCatalogState, Series from pullbox.models.story_arc import ImportedStoryArcStatus from pullbox.models.story_arc_import import ImportedStoryArc +from pullbox.services.import_metadata_follow_up import failed_metadata_write_filter from pullbox.ui.import_orphaned_routes import load_import_orphaned_context from pullbox.ui.import_results_context import load_import_results_context @@ -113,6 +114,12 @@ def _follow_up_job_filter() -> ColumnElement[bool]: misplaced_files, unresolved_story_arcs, failed_metadata, + select(ImportedFile.id) + .where( + ImportedFile.import_job_id == ImportJob.id, + failed_metadata_write_filter(), + ) + .exists(), ) diff --git a/src/pullbox/ui/import_results_context.py b/src/pullbox/ui/import_results_context.py index b1c9efba..31b6111c 100644 --- a/src/pullbox/ui/import_results_context.py +++ b/src/pullbox/ui/import_results_context.py @@ -1083,6 +1083,9 @@ async def load_import_results_context( files_no_match - orphaned_file_no_match_count, 0, ) + from pullbox.services.import_metadata_follow_up import count_failed_metadata_writes + + archive_metadata_failed_count = await count_failed_metadata_writes(session, job_id) catalog_sync_series = await _load_catalog_sync_series(session, job_id) catalog_sync_failed_count = sum( 1 @@ -1103,6 +1106,7 @@ async def load_import_results_context( + int(job.status is ImportJobStatus.FAILED and files_safety_blocked > 0) + int(story_arc_results_summary["story_arcs_follow_up_count"] > 0) + int(catalog_sync_failed_count > 0) + + int(archive_metadata_failed_count > 0) ) rollback_incomplete = bool( rollback_journal_summary["rollback_manual_recovery_count"] @@ -1133,6 +1137,7 @@ async def load_import_results_context( "orphaned_file_no_match_count": orphaned_file_no_match_count, "identified_series_file_no_match_count": identified_series_file_no_match_count, "catalog_sync_pending_count": catalog_sync_pending_count, + "archive_metadata_failed_count": archive_metadata_failed_count, "catalog_sync_failed_count": catalog_sync_failed_count, "catalog_sync_attention_count": len(catalog_sync_series), "catalog_sync_series": catalog_sync_series, diff --git a/src/pullbox/ui/import_routes.py b/src/pullbox/ui/import_routes.py index 2095d7bc..3829f57c 100644 --- a/src/pullbox/ui/import_routes.py +++ b/src/pullbox/ui/import_routes.py @@ -116,6 +116,71 @@ def _ctx(request: Request, user: object | None = None, **kwargs: object) -> dict return dict(context) +@router.get( + "/import/{job_id}/metadata-writes", response_class=HTMLResponse, include_in_schema=False +) +async def import_metadata_write_follow_up( + job_id: int, + request: Request, + user: AuthenticatedUser, + session: DbSession, + page: int = Query(1, ge=1), +) -> Response: + from pullbox.services.import_metadata_follow_up import load_metadata_write_follow_up + + return _templates().TemplateResponse( + request, + "partials/import_metadata_write_follow_up.html", + _ctx(request, user, **await load_metadata_write_follow_up(session, job_id, page=page)), + ) + + +@router.post( + "/import/{job_id}/files/{file_id}/retry-metadata", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_retry_metadata_write( + job_id: int, + file_id: int, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + page: int = Query(1, ge=1), +) -> Response: + from pullbox.api.deps import get_request_session_factory + from pullbox.composition.services import build_import_service + from pullbox.services.import_metadata_follow_up import ( + load_metadata_write_follow_up, + retry_import_metadata_write, + ) + + message, error = "Metadata writing is already queued or complete.", None + try: + service = await build_import_service(session) + queued = await retry_import_metadata_write(session, job_id, file_id, actor=user.username) + await session.commit() + if queued: + service.schedule_comicinfo_enrichment( + get_request_session_factory(request), job_id=job_id + ) + message = "Metadata retry queued. Follow its progress in the activity menu." + except ValidationError as exc: + await session.rollback() + error = exc.message + return _templates().TemplateResponse( + request, + "partials/import_metadata_write_follow_up.html", + _ctx( + request, + user, + metadata_message=message if error is None else None, + metadata_error=error, + **await load_metadata_write_follow_up(session, job_id, page=page), + ), + ) + + def _object_to_int(value: object, default: int = 0) -> int: if isinstance(value, bool): return int(value) diff --git a/src/pullbox/ui/templates/partials/import_metadata_write_follow_up.html b/src/pullbox/ui/templates/partials/import_metadata_write_follow_up.html new file mode 100644 index 00000000..9a0cd25f --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_metadata_write_follow_up.html @@ -0,0 +1,39 @@ +{% from "components/pagination.html" import pagination %} +{% from "components/settings_shell.html" import inline_alert %} +
+ {% if metadata_error %} + {% call inline_alert(tone="error", icon="warning") %}{{ metadata_error }}{% endcall %} + {% elif metadata_message %} +

{{ metadata_message }}

+ {% endif %} +

{{ metadata_total }} failed metadata {{ 'write' if metadata_total == 1 else 'writes' }} remaining. A retry updates metadata only; it does not move files or approve a different issue match.

+ {% for file in metadata_files %} + {% set details = file.diagnostics.get('comicinfo_enrichment', {}) %} + {% set code = details.get('error', '') %} +
+

{{ file.file_name }}

+

+ {% if code in ['reference_only', 'root_not_managed', 'readonly_source', 'source_unavailable'] %} + Check library-root access and write permissions. Files kept in place remain protected; don't enable managed writes on a shared library unless you intend Pullbox to modify it. + {% elif code in ['metadata_conflict', 'metadata_requires_review', 'identity_requires_review', 'parent_identity_missing'] %} + The saved identities or embedded metadata need review. Open the issue and check its match and archive metadata before retrying. Retry does not override conflicting metadata. + {% elif code in ['source_changed', 'issue_changed', 'binding_changed', 'import_owner_changed'] %} + This file or its registration changed. Rescan the series to reconcile the current file before retrying. Pullbox will not replace a changed file using the old import's permission. + {% else %} + Download the import log for the failure details, correct the reported problem, then retry. The comic remains registered in your library. + {% endif %} +

+
+ {% if file.matched_issue_id %}Open issue{% endif %} + +
+
+ {% endfor %} + {% if metadata_pages > 1 %} + {{ pagination(metadata_page, metadata_pages, '/import/' ~ job.id ~ '/metadata-writes', hx_target='#import-metadata-write-files', hx_swap='innerHTML', hx_select='', hx_push_url='false', id_prefix='metadata-writes-') }} + {% endif %} +
+

{% if metadata_total %}{{ metadata_total }} {{ 'file needs' if metadata_total == 1 else 'files need' }} a metadata retry{% else %}No failed metadata writes remaining{% endif %}

diff --git a/src/pullbox/ui/templates/partials/import_results.html b/src/pullbox/ui/templates/partials/import_results.html index b51e62ef..ca8c4f34 100644 --- a/src/pullbox/ui/templates/partials/import_results.html +++ b/src/pullbox/ui/templates/partials/import_results.html @@ -512,6 +512,24 @@

{{ catalog_sync_failed_count }} series need meta {% endif %} + {% if archive_metadata_failed_count|default(0) > 0 %} +
+
+
+

Archive metadata

+

{{ archive_metadata_failed_count }} {{ 'file needs' if archive_metadata_failed_count == 1 else 'files need' }} a metadata retry

+

These comics were imported, but their embedded metadata could not be updated. Review the problem before retrying; you don't need to import them again.

+
+
+ +
+
+
+
+ {% endif %} + {% if failed_series %}
diff --git a/tests/api/test_import_metadata_follow_up.py b/tests/api/test_import_metadata_follow_up.py new file mode 100644 index 00000000..46246fba --- /dev/null +++ b/tests/api/test_import_metadata_follow_up.py @@ -0,0 +1,101 @@ +"""Authenticated, scoped follow-up uses the existing metadata background job.""" + +from unittest.mock import AsyncMock, MagicMock + +from pullbox.models.import_job import ImportedFile, ImportJobAction +from tests.api.test_import_completed_cleanup_api import _csrf_header_for +from tests.integration.metadata_identity.test_import_archive_publication import owned +from tests.integration.metadata_identity.test_import_metadata_retry import fail_for_root + +pytest_plugins = ["conftest_security"] + + +async def test_followup_retry_commits_before_scheduling_and_never_reimports( + authenticated_client, + sec_db, + tmp_path, + monkeypatch, +): + async with owned(sec_db, tmp_path) as (path, _, plan, ids, _): + await fail_for_root(sec_db, plan, ids) + service = MagicMock() + monkeypatch.setattr( + "pullbox.composition.services.build_import_service", AsyncMock(return_value=service) + ) + monkeypatch.setattr("pullbox.database.get_session_factory", lambda: sec_db) + before = path.read_bytes() + async with sec_db() as session: + original = (await session.get(ImportJobAction, ids[2])).payload + detail = await authenticated_client.get(f"/import/{ids[0]}/metadata-writes") + assert detail.status_code == 200 + assert 'class="btn-primary btn-sm"' in detail.text and "Retry metadata" in detail.text + assert "write permissions" in detail.text + response = await authenticated_client.post( + f"/import/{ids[0]}/files/{ids[1]}/retry-metadata", + headers=_csrf_header_for(authenticated_client), + ) + assert response.status_code == 200 and "Metadata retry queued" in response.text + service.schedule_comicinfo_enrichment.assert_called_once_with(sec_db, job_id=ids[0]) + async with sec_db() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "pending" + assert (await session.get(ImportJobAction, ids[2])).payload == original + assert path.read_bytes() == before + response = await authenticated_client.post( + f"/import/{ids[0]}/files/{ids[1]}/retry-metadata", + headers=_csrf_header_for(authenticated_client), + ) + assert response.status_code == 200 and "already queued" in response.text + assert service.schedule_comicinfo_enrichment.call_count == 1 + + +async def test_metadata_retry_requires_csrf_and_operator( + authenticated_client, + sec_db, + sec_api_key, + tmp_path, +): + async with owned(sec_db, tmp_path) as (_, _, plan, ids, _): + await fail_for_root(sec_db, plan, ids) + url = f"/import/{ids[0]}/files/{ids[1]}/retry-metadata" + response = await authenticated_client.post(url) + assert response.status_code == 403 + authenticated_client.cookies.clear() + response = await authenticated_client.post(url, headers={"X-API-Key": sec_api_key}) + assert response.status_code == 401 + async with sec_db() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + + +async def test_metadata_follow_up_is_bounded_and_escapes_names( + authenticated_client, + sec_db, + tmp_path, +): + async with owned(sec_db, tmp_path) as (_, _, plan, ids, _): + await fail_for_root(sec_db, plan, ids) + async with sec_db.begin() as session: + file = await session.get(ImportedFile, ids[1]) + file.file_name = ".cbz" + for number in range(25): + session.add( + ImportedFile( + import_job_id=file.import_job_id, + import_series_id=file.import_series_id, + file_path=f"/imports/{number}.cbz", + file_name=f"Extra {number}.cbz", + file_format="cbz", + status=file.status, + diagnostics=file.diagnostics, + ) + ) + response = await authenticated_client.get(f"/import/{ids[0]}/metadata-writes") + assert response.status_code == 200 + assert response.text.count('data-testid="metadata-write-') == 25 + assert "26 failed metadata writes remaining" in response.text + assert "<script>not executable</script>.cbz" in response.text + assert "page=2" in response.text + response = await authenticated_client.get(f"/import/{ids[0]}/metadata-writes?page=2") + assert response.status_code == 200 + assert response.text.count('data-testid="metadata-write-') == 1 diff --git a/tests/e2e/test_import_metadata_follow_up.py b/tests/e2e/test_import_metadata_follow_up.py new file mode 100644 index 00000000..d2596dc9 --- /dev/null +++ b/tests/e2e/test_import_metadata_follow_up.py @@ -0,0 +1,150 @@ +"""Archive metadata follow-up keeps the import page stable during review/retry.""" + +import re + +import pytest +from playwright.sync_api import expect +from sqlalchemy import delete, select + +from pullbox.models import Issue, LibraryFile, LibraryRoot +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.library import FileFormat +from pullbox.services.import_service import ImportService +from tests.e2e.accessibility import assert_no_axe_violations +from tests.e2e.conftest import _run_async_blocking, wait_for_htmx + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def metadata_follow_up_job(seeded_server, tmp_path, monkeypatch): + from datetime import UTC, datetime + + from pullbox.database import get_session_factory + + async def seed(): + async with get_session_factory().begin() as session: + issue = await session.scalar(select(Issue).order_by(Issue.id).limit(1)) + root = await session.scalar(select(LibraryRoot).order_by(LibraryRoot.id).limit(1)) + job = ImportJob( + source_path="/tmp/metadata-follow-up-test", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + session.add(job) + await session.flush() + series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Metadata follow-up test", + status=ImportSeriesStatus.IMPORTED, + series_id=issue.series_id, + ) + session.add(series) + await session.flush() + library = LibraryFile( + file_path=str(tmp_path / "test.cbz"), + file_name="test.cbz", + file_size=1, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + issue_id=issue.id, + library_root_id=root.id, + ) + session.add(library) + await session.flush() + for number in range(26): + session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=series.id, + file_path=f"/imports/metadata-{number}.cbz", + file_name=f"Metadata {number}.cbz", + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + library_file_id=library.id, + diagnostics={ + "comicinfo_enrichment": {"status": "failed", "error": "readonly_source"} + }, + ) + ) + return job.id, library.id + + # Real retry persistence is exercised; don't launch provider work from this UI-only fixture. + calls = [] + monkeypatch.setattr( + ImportService, + "schedule_comicinfo_enrichment", + lambda self, factory, *, job_id: calls.append(job_id), + ) + job_id, library_id = _run_async_blocking(seed()) + yield job_id, calls + + async def cleanup(): + async with get_session_factory().begin() as session: + await session.execute(delete(ImportJob).where(ImportJob.id == job_id)) + await session.execute(delete(LibraryFile).where(LibraryFile.id == library_id)) + + _run_async_blocking(cleanup()) + + +def test_metadata_follow_up_paginates_and_retries_without_navigation( + authed_page, + seeded_server, + metadata_follow_up_job, +): + job_id, calls = metadata_follow_up_job + page = authed_page + page.goto(f"{seeded_server}/import?tab=follow-up&job_id={job_id}") + card = page.get_by_test_id("import-follow-up-archive-metadata") + expect(card).to_be_visible() + page.evaluate("window.metadataFollowUpMarker = 'same document'") + card.get_by_role("button", name="Review files", exact=True).click() + panel = page.locator("#import-metadata-write-files") + expect(panel.locator("article")).to_have_count(25) + panel.locator("#metadata-writes-pagination-next").click() + expect(panel.locator("article")).to_have_count(1) + wait_for_htmx(page) + expect(panel).not_to_have_class(re.compile("htmx-settling")) + panel.get_by_role("button", name="Retry metadata", exact=True).focus() + expect(panel.get_by_role("button", name="Retry metadata", exact=True)).to_be_focused() + with page.expect_response(lambda response: "/retry-metadata" in response.url) as response: + page.keyboard.press("Enter") + assert response.value.status == 200, response.value.text() + expect(panel.get_by_role("status")).to_contain_text("Metadata retry queued") + expect(panel.locator("article")).to_have_count(25) + expect(card.locator("#import-metadata-write-heading")).to_have_text( + "25 files need a metadata retry" + ) + assert calls == [job_id] + assert page.evaluate("window.metadataFollowUpMarker") == "same document" + expect(page.get_by_test_id("import-header")).to_have_count(1) + expect(page.locator("#import-orphaned-results")).to_have_count(1) + page.set_viewport_size({"width": 360, "height": 800}) + expect(panel.get_by_role("button", name="Retry metadata", exact=True).first).to_be_visible() + + +@pytest.mark.accessibility +def test_metadata_follow_up_has_no_wcag_aa_violations( + authed_page, seeded_server, metadata_follow_up_job +): + job_id, _ = metadata_follow_up_job + page = authed_page + page.goto(f"{seeded_server}/import?tab=follow-up&job_id={job_id}") + card = page.get_by_test_id("import-follow-up-archive-metadata") + card.get_by_role("button", name="Review files", exact=True).click() + expect(card.locator("article")).to_have_count(25) + assert_no_axe_violations( + page, + name="import archive metadata follow-up", + include=["[data-testid='import-follow-up-archive-metadata']"], + ) + page.screenshot(path="test-results/import-metadata-follow-up.png") diff --git a/tests/integration/metadata_identity/test_import_metadata_enrichment.py b/tests/integration/metadata_identity/test_import_metadata_enrichment.py new file mode 100644 index 00000000..e4f081fe --- /dev/null +++ b/tests/integration/metadata_identity/test_import_metadata_enrichment.py @@ -0,0 +1,560 @@ +"""The normal import background entrypoint writes one reconciled XML pair.""" + +from contextlib import asynccontextmanager +from datetime import UTC, date, datetime +from unittest.mock import AsyncMock, MagicMock +from zipfile import ZipFile + +import pytest +from defusedxml import ElementTree +from sqlalchemy import delete, select + +from pullbox.core.library_file_ownership import build_managed_placement_signature +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.models import Issue, LibraryFile, LibraryRoot, Series +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState +from pullbox.models.import_job import ( + ImportedFile, + ImportedSeries, + ImportJobAction, + ImportJobActionStatus, + ImportSeriesStatus, +) +from pullbox.models.library import LibraryFileStorageMode +from pullbox.models.metadata_baseline import IssueMetadataBaseline +from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.providers.base import IssueMetadata +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.services.import_service import ImportService +from tests.integration.metadata_identity.test_import_archive_publication import owned, rollback + + +def import_service(): + service = ImportService(MagicMock(), MagicMock(), MagicMock()) + # Provider catalog hydration has already supplied the canonical DB values. + service._metadata_service.prefetch_issue_metadata_batch = AsyncMock(return_value={}) + service._build_comicinfo_payload_for_issue = AsyncMock( + return_value={"Series": "Example", "Number": "50-X"} + ) + return service + + +async def add_comicvine_target(factory, plan, ids): + async with factory.begin() as session: + series = await session.get(Series, plan.target.binding.metadata.series.local_id) + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + series.comicvine_id, issue.comicvine_id = 123, 456 + session.add_all( + [ + SeriesExternalIdentity( + series_id=series.id, + identity_namespace="comicvine", + external_id="123", + verification_state="verified", + evidence_kind="provider_result", + ), + IssueExternalIdentity( + issue_id=issue.id, + identity_namespace="comicvine", + external_id="456", + verification_state="verified", + evidence_kind="provider_result", + ), + ] + ) + file = await session.get(ImportedFile, ids[1]) + file.diagnostics = { + **file.diagnostics, + "comicinfo_enrichment": { + **file.diagnostics["comicinfo_enrichment"], + "issue_cv_id": 456, + }, + } + + +def full_issue(**changes): + return IssueMetadata( + **{ + "provider_id": "456", + "series_provider_id": "123", + "issue_number": 50, + "issue_number_text": "50-X", + "title": "Provider title", + "description": "The missing provider summary", + "release_date": "1996-05-01", + "store_date": "1996-04-17", + "cover_url": None, + "page_count": 32, + "comicvine_url": "https://comicvine.gamespot.com/issue/4000-456/", + "creators": [{"name": "A Writer", "role": "writer"}], + **changes, + } + ) + + +async def test_real_job_uses_batch_fields_without_overwriting_local_metadata( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, _): + await add_comicvine_target(factory, plan, ids) + async with factory.begin() as session: + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + issue.title = "My local title" + service = import_service() + service._metadata_service.prefetch_issue_metadata_batch.return_value = {456: full_issue()} + original = source.read_bytes() + await service.recover_pending_comicinfo_enrichment(factory) + with ZipFile(path) as archive: + ci = ElementTree.fromstring(archive.read("ComicInfo.xml")) + mi = ElementTree.fromstring(archive.read("MetronInfo.xml")) + assert ( + ci.findtext("Summary") == mi.findtext("Summary") == "The missing provider summary" + ) + assert ci.findtext("Title") == mi.findtext("Stories/Story") == "My local title" + assert ci.findtext("Writer") == mi.findtext("Credits/Credit/Creator") == "A Writer" + assert ci.findtext("PageCount") == mi.findtext("PageCount") == "32" + assert archive.read("page.jpg") == b"page bytes" + async with factory() as session: + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + assert issue.description == "The missing provider summary" + assert issue.release_date == date(1996, 5, 1) + assert issue.store_date == date(1996, 4, 17) + baseline = await session.scalar(select(IssueMetadataBaseline)) + snapshot = MetadataSnapshot.model_validate_json(baseline.snapshot_json) + origin = next(item for item in snapshot.origins if item.field == "description") + assert origin.source.value == "comicvine_api" + service._metadata_service.prefetch_issue_metadata_batch.assert_awaited_once_with([456]) + service._build_comicinfo_payload_for_issue.assert_not_awaited() + assert source.read_bytes() == original + + +@pytest.mark.parametrize( + "bad_field,bad_value", + [ + ("provider_id", "789"), + ("series_provider_id", "789"), + ("issue_number_text", "50-O"), + ], +) +async def test_wrong_batch_identity_never_enriches_or_rewrites( + identity_probe_db, tmp_path, bad_field, bad_value +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + await add_comicvine_target(factory, plan, ids) + service = import_service() + service._metadata_service.prefetch_issue_metadata_batch.return_value = { + 456: full_issue(**{bad_field: bad_value}) + } + before = path.read_bytes() + await service.recover_pending_comicinfo_enrichment(factory) + assert path.read_bytes() == before + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + assert issue.description is None + + +@pytest.mark.parametrize("local", ["cleared", "embedded"]) +async def test_batch_enrichment_preserves_local_summary_and_credit_clears( + identity_probe_db, tmp_path, local +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + await add_comicvine_target(factory, plan, ids) + if local == "cleared": + previous = MetadataSnapshot( + entity_kind=MetadataEntityKind.ISSUE, + identities=plan.issue.identities, + values=MetadataValues( + description="Previously present", + issue_number_text="50-X", + credits=({"name": "Previous writer", "role": "writer"},), + ), + ) + async with factory.begin() as session: + session.add( + IssueMetadataBaseline( + issue_id=plan.target.binding.metadata.issues[0].local_id, + revision=1, + snapshot_json=previous.model_dump_json(), + ) + ) + else: + with ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"page bytes") + archive.writestr( + "ComicInfo.xml", + "50-XEmbedded summary" + "Local writer", + ) + await capture_fixture_signature(factory, path, plan, ids) + service = import_service() + service._metadata_service.prefetch_issue_metadata_batch.return_value = {456: full_issue()} + await service.recover_pending_comicinfo_enrichment(factory) + with ZipFile(path) as archive: + ci = ElementTree.fromstring(archive.read("ComicInfo.xml")) + mi = ElementTree.fromstring(archive.read("MetronInfo.xml")) + expected = None if local == "cleared" else "Embedded summary" + assert ci.findtext("Summary") == mi.findtext("Summary") == expected + if local == "cleared": + # Empty containers distinguish a credit clear from missing metadata. + assert ci.findtext("Writer") == "" + assert mi.find("Credits") is not None + assert mi.findall("Credits/Credit") == [] + else: + assert ( + ci.findtext("Writer") == mi.findtext("Credits/Credit/Creator") == "Local writer" + ) + assert ci.findtext("Title") == "Provider title" + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + baseline = await session.scalar(select(IssueMetadataBaseline)) + snapshot = MetadataSnapshot.model_validate_json(baseline.snapshot_json) + for field in ("description", "credits"): + origin = next(item for item in snapshot.origins if item.field == field) + assert origin.user_override if local == "cleared" else origin.embedded_documents + + +async def test_disabled_source_is_not_prefetched_for_paired_enrichment(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + await add_comicvine_target(factory, plan, ids) + async with factory.begin() as session: + session.add(MetadataSourceConfig(source="comicvine_api", enabled=False, priority=20)) + service = import_service() + service._metadata_service.prefetch_issue_metadata_batch.return_value = {456: full_issue()} + await service.recover_pending_comicinfo_enrichment(factory) + service._metadata_service.prefetch_issue_metadata_batch.assert_not_awaited() + with ZipFile(path) as archive: + assert "MetronInfo.xml" in archive.namelist() + assert ElementTree.fromstring(archive.read("ComicInfo.xml")).findtext("Summary") is None + + +async def test_failed_batch_fetch_leaves_work_pending_without_rewriting( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + await add_comicvine_target(factory, plan, ids) + service = import_service() + service._metadata_service.prefetch_issue_metadata_batch.side_effect = TimeoutError() + before = path.read_bytes() + await service.recover_pending_comicinfo_enrichment(factory) + assert path.read_bytes() == before + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "pending" + service._metadata_service.prefetch_issue_metadata_batch.side_effect = None + service._metadata_service.prefetch_issue_metadata_batch.return_value = {456: full_issue()} + await service.recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + with ZipFile(path) as archive: + assert ElementTree.fromstring(archive.read("ComicInfo.xml")).findtext("Summary") == ( + "The missing provider summary" + ) + + +async def test_failed_archive_write_remains_visible_in_import_follow_up( + identity_probe_db, tmp_path +): + from pullbox.models.import_job import ImportJob + from pullbox.ui.import_follow_up import count_import_follow_up_jobs + from pullbox.ui.import_results_context import load_import_results_context + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + async with factory.begin() as session: + file = await session.get(ImportedFile, ids[1]) + imported_series = await session.get(ImportedSeries, file.import_series_id) + imported_series.status = ImportSeriesStatus.IMPORTED + root = await session.get(LibraryRoot, plan.target.binding.library_root_id) + root.allow_managed_writes = False + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + assert await count_import_follow_up_jobs(session) == 1 + job = await session.get(ImportJob, ids[0]) + context = await load_import_results_context(session, job, include_clean_library=False) + assert context["archive_metadata_failed_count"] == 1 + assert context["follow_up_group_count"] == 1 + + +@pytest.mark.parametrize("providers", ["metron", "comicvine", "both", "metron_first", "existing"]) +async def test_normal_import_background_job_writes_both_xml_and_retains_rollback( + identity_probe_db, tmp_path, providers +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, signature): + if providers != "metron": + async with factory.begin() as session: + if providers == "comicvine": + await session.execute(delete(IssueExternalIdentity)) + await session.execute(delete(SeriesExternalIdentity)) + series = await session.get(Series, plan.target.binding.metadata.series.local_id) + issue = await session.get(Issue, plan.target.binding.metadata.issues[0].local_id) + series.comicvine_id, issue.comicvine_id = 123, 456 + session.add_all( + [ + SeriesExternalIdentity( + series_id=series.id, + identity_namespace="comicvine", + external_id="123", + verification_state="verified", + evidence_kind="provider_result", + ), + IssueExternalIdentity( + issue_id=issue.id, + identity_namespace="comicvine", + external_id="456", + verification_state="verified", + evidence_kind="provider_result", + ), + ] + ) + if providers == "metron_first": + session.add( + MetadataSourceConfig( + source="metron_api", + enabled=True, + priority=100, + domain_priorities={"core": 0}, + ) + ) + if providers == "existing": + with ZipFile(path, "a") as archive: + archive.writestr( + "MetronInfo.xml", + '42' + "Example50-X", + ) + signature = await capture_fixture_signature(factory, path, plan, ids) + original_source = source.read_bytes() + service = import_service() + assert await service.recover_pending_comicinfo_enrichment(factory) == 1 + with ZipFile(path) as archive: + assert "MetronInfo.xml" in archive.namelist(), "The real job must write both documents" + assert "ComicInfo.xml" in archive.namelist() + assert archive.read("page.jpg") == b"page bytes" + ci = ElementTree.fromstring(archive.read("ComicInfo.xml")) + mi = ElementTree.fromstring(archive.read("MetronInfo.xml")) + validate_metroninfo_xml(archive.read("MetronInfo.xml")) + assert ci.findtext("Series") == mi.findtext("Series/Name") == "Example" + assert ci.findtext("Number") == mi.findtext("Number") == "50-X" + assert len(mi.findall("IDS/ID")) == (1 if providers in {"comicvine", "metron"} else 2) + primary = mi.find("IDS/ID[@primary='true']") + assert primary is not None + assert primary.attrib["source"] == ( + "Comic Vine" if providers in {"comicvine", "both"} else "Metron" + ) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + publication = await session.scalar(select(ArchiveMetadataPublication)) + assert publication is not None and publication.state is PublicationState.FINALIZED + action = await session.get(ImportJobAction, ids[2]) + assert action.payload["destination_signature"] == signature + assert action.payload["metadata_publication"] == publication.operation_id + before = path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns + assert await service.recover_pending_comicinfo_enrichment(factory) == 0 + assert (path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) == before + assert source.read_bytes() == original_source + assert await rollback(factory, ids[2]) is ImportJobActionStatus.ROLLED_BACK + assert not path.exists() and source.read_bytes() == original_source + + +@pytest.mark.parametrize("protection", ["referenced", "read_only_root"]) +async def test_normal_job_does_not_rewrite_a_protected_file( + identity_probe_db, tmp_path, protection +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + async with factory.begin() as session: + if protection == "referenced": + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + file.storage_mode = LibraryFileStorageMode.REFERENCED + else: + root = await session.get(LibraryRoot, plan.target.binding.library_root_id) + root.allow_managed_writes = False + before = path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns + await import_service().recover_pending_comicinfo_enrichment(factory) + assert (path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) == before + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + + +async def capture_fixture_signature(factory, path, plan, ids): + """Model registration of the fixture bytes before its background job starts.""" + signature = build_managed_placement_signature(path) + async with factory.begin() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + file.file_size = path.stat().st_size + file.file_modified_at = datetime.fromtimestamp(path.stat().st_mtime, UTC) + file.source_signature = signature + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "destination_signature": signature} + return signature + + +@pytest.mark.parametrize("approved,dangerous", [(False, False), (True, False), (True, True)]) +async def test_size_approval_never_overrides_dangerous_content( + identity_probe_db, tmp_path, monkeypatch, approved, dangerous +): + from pullbox.tasks import import_metadata_writing + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + if dangerous: + with ZipFile(path, "a") as archive: + archive.writestr("payload.exe", b"not a comic page") + await capture_fixture_signature(factory, path, plan, ids) + if approved: + async with factory.begin() as session: + file = await session.get(ImportedFile, ids[1]) + file.diagnostics = { + **file.diagnostics, + "safety_exception": { + "allowed_once": True, + "previous_block": {"code": "archive_size_limit", "overrideable": True}, + }, + } + monkeypatch.setattr( + import_metadata_writing, "get_archive_size_limit_bytes", AsyncMock(return_value=1) + ) + before = path.read_bytes() + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == ( + "complete" if approved and not dangerous else "failed" + ) + if not approved or dangerous: + assert path.read_bytes() == before + + +async def test_normal_job_reports_identity_conflict_without_mutating_archive( + identity_probe_db, tmp_path +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + with ZipFile(path, "w") as archive: + archive.writestr("page.jpg", b"page bytes") + archive.writestr( + "ComicInfo.xml", + "50-X[cv_issue_id:999]", + ) + signature = build_managed_placement_signature(path) + async with factory.begin() as session: + file = await session.get(LibraryFile, plan.target.binding.library_file_id) + file.file_size = path.stat().st_size + file.file_modified_at = datetime.fromtimestamp(path.stat().st_mtime, UTC) + file.source_signature = signature + action = await session.get(ImportJobAction, ids[2]) + action.payload = {**action.payload, "destination_signature": signature} + before = path.read_bytes() + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + assert path.read_bytes() == before + + +async def test_publication_failure_keeps_owner_pending_for_restart( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.services import archive_metadata_finalization + from pullbox.tasks import import_metadata_writing + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, _, ids, _): + finish = archive_metadata_finalization.finalize_archive_publication + monkeypatch.setattr( + import_metadata_writing, + "finalize_archive_publication", + AsyncMock(side_effect=RuntimeError("interrupted finalization")), + ) + service = import_service() + await service.recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "pending" + monkeypatch.setattr(import_metadata_writing, "finalize_archive_publication", finish) + published = path.read_bytes(), path.stat().st_ino + await service.recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + assert (path.read_bytes(), path.stat().st_ino) == published + assert source.exists() + + +async def test_changed_queued_issue_is_not_silently_rebound(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, _, ids, _): + async with factory.begin() as session: + file = await session.get(ImportedFile, ids[1]) + details = dict(file.diagnostics["comicinfo_enrichment"]) + details["issue_id"] = 123456 + file.diagnostics = {**file.diagnostics, "comicinfo_enrichment": details} + before = path.read_bytes() + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + assert path.read_bytes() == before + + +async def test_verified_imported_issue_can_write_while_rest_of_catalog_hydrates( + identity_probe_db, tmp_path +): + from pullbox.models.series import IssueCatalogState + + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, _, plan, ids, _): + async with factory.begin() as session: + series = await session.get(Series, plan.target.binding.metadata.series.local_id) + series.issue_catalog_state = IssueCatalogState.HYDRATING + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "complete" + series = await session.get(Series, plan.target.binding.metadata.series.local_id) + assert series.issue_catalog_state is IssueCatalogState.HYDRATING + with ZipFile(path) as archive: + assert "MetronInfo.xml" in archive.namelist() + + +async def test_durable_cancel_during_staging_preserves_archive_and_pending_work( + identity_probe_db, tmp_path, monkeypatch +): + from pullbox.models.import_job import ImportControlRequest, ImportJob + from pullbox.tasks import import_metadata_writing + + _, factory, _ = identity_probe_db + real_stage = import_metadata_writing.stage_cbz_metadata_interruptible + async with owned(factory, tmp_path) as (path, _, _, ids, _): + + @asynccontextmanager + async def cancel_before_handoff(*args, **kwargs): + async with real_stage(*args, **kwargs) as stage: + async with factory.begin() as session: + job = await session.get(ImportJob, ids[0]) + job.control_request = ImportControlRequest.CANCEL + yield stage + + monkeypatch.setattr( + import_metadata_writing, "stage_cbz_metadata_interruptible", cancel_before_handoff + ) + before = path.read_bytes() + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "pending" + assert path.read_bytes() == before diff --git a/tests/integration/metadata_identity/test_import_metadata_retry.py b/tests/integration/metadata_identity/test_import_metadata_retry.py new file mode 100644 index 00000000..7f732530 --- /dev/null +++ b/tests/integration/metadata_identity/test_import_metadata_retry.py @@ -0,0 +1,94 @@ +"""Metadata retry never reimports or replaces an import's original evidence.""" + +from copy import deepcopy + +import pytest +from sqlalchemy import select + +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.models import LibraryRoot +from pullbox.models.import_job import ImportControlRequest, ImportedFile, ImportJob, ImportJobAction +from pullbox.services.import_metadata_follow_up import retry_import_metadata_write +from tests.integration.metadata_identity.test_import_archive_publication import owned, publish +from tests.integration.metadata_identity.test_import_metadata_enrichment import import_service + + +async def fail_for_root(factory, plan, ids): + async with factory.begin() as session: + root = await session.get(LibraryRoot, plan.target.binding.library_root_id) + root.allow_managed_writes = False + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + + +@pytest.mark.parametrize("changed", [False, True]) +async def test_retry_only_requeues_metadata_and_revalidates_archive( + identity_probe_db, tmp_path, changed +): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (path, source, plan, ids, _): + await fail_for_root(factory, plan, ids) + if changed: + path.write_bytes(b"Replacement that this import does not own") + before, original = path.read_bytes(), source.read_bytes() + async with factory.begin() as session: + root = await session.get(LibraryRoot, plan.target.binding.library_root_id) + root.allow_managed_writes = True + async with factory.begin() as session: + action = await session.get(ImportJobAction, ids[2]) + evidence = deepcopy(action.payload) + assert await retry_import_metadata_write(session, ids[0], ids[1], actor="test") + assert not await retry_import_metadata_write(session, ids[0], ids[1], actor="test") + assert action.payload == evidence + assert path.read_bytes() == before + await import_service().recover_pending_comicinfo_enrichment(factory) + async with factory() as session: + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == ( + "failed" if changed else "complete" + ) + action = await session.get(ImportJobAction, ids[2]) + assert { + key: value for key, value in action.payload.items() if key != "metadata_publication" + } == evidence + if changed: + assert path.read_bytes() == before + assert source.read_bytes() == original + + +async def test_retry_cannot_change_live_publication_owner(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + await publish(factory, plan) + async with factory.begin() as session: + file = await session.get(ImportedFile, ids[1]) + file.diagnostics = { + **file.diagnostics, + "comicinfo_enrichment": { + **file.diagnostics["comicinfo_enrichment"], + "status": "failed", + }, + } + async with factory.begin() as session: + with pytest.raises(ValidationError, match="needs recovery"): + await retry_import_metadata_write(session, ids[0], ids[1], actor="test") + file = await session.get(ImportedFile, ids[1]) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" + + +async def test_retry_rejects_stopped_job_and_wrong_import(identity_probe_db, tmp_path): + _, factory, _ = identity_probe_db + async with owned(factory, tmp_path) as (_, _, plan, ids, _): + await fail_for_root(factory, plan, ids) + async with factory.begin() as session: + with pytest.raises(NotFoundError): + await retry_import_metadata_write(session, ids[0], ids[1] + 1, actor="test") + job = await session.get(ImportJob, ids[0]) + job.control_request = ImportControlRequest.CANCEL + async with factory.begin() as session: + with pytest.raises(ValidationError, match="no longer ready"): + await retry_import_metadata_write(session, ids[0], ids[1], actor="test") + file = await session.scalar(select(ImportedFile)) + assert file.diagnostics["comicinfo_enrichment"]["status"] == "failed" diff --git a/tests/ui/test_ui_router_contract.py b/tests/ui/test_ui_router_contract.py index 7b85e176..734c4237 100644 --- a/tests/ui/test_ui_router_contract.py +++ b/tests/ui/test_ui_router_contract.py @@ -153,6 +153,18 @@ def test_ui_router_manifest_remains_stable_during_decomposition() -> None: "apply_file_assignment", "apply_file_assignment", ), + ( + "/import/{job_id}/metadata-writes", + ("GET",), + "import_metadata_write_follow_up", + "import_metadata_write_follow_up", + ), + ( + "/import/{job_id}/files/{file_id}/retry-metadata", + ("POST",), + "import_retry_metadata_write", + "import_retry_metadata_write", + ), ("/import", ("GET",), "import_page", "import_page"), ( "/import/{job_id}/progress-partial", diff --git a/tests/unit/test_archive_metadata_rendering.py b/tests/unit/test_archive_metadata_rendering.py index d222fe77..8b4ab200 100644 --- a/tests/unit/test_archive_metadata_rendering.py +++ b/tests/unit/test_archive_metadata_rendering.py @@ -113,6 +113,33 @@ def test_multiple_verified_ids_require_explicit_or_existing_primary(): ) +def test_added_verified_identity_preserves_prior_baseline_and_local_clear(): + old_series, old_issue = snapshots(description="Old summary") + series = old_series.model_copy(update={"identities": (CV_SERIES, METRON_SERIES)}) + issue = old_issue.model_copy( + update={ + "identities": (CV_ISSUE, METRON_ISSUE), + "values": old_issue.values.model_copy(update={"description": None}), + "origins": (origin("description", user=True),), + } + ) + pair = render_archive_metadata( + series, + issue, + files("Old summary"), + primary_identity=CV_ISSUE, + previous_series=old_series, + previous_issue=old_issue, + ) + assert ET.fromstring(pair.comicinfo).findtext("Summary") is None + assert parse_metroninfo(pair.metroninfo).summary is None + assert {e.identity for e in parse_metroninfo(pair.metroninfo).evidence} == { + CV_SERIES, + CV_ISSUE, + METRON_ISSUE, + } + + @pytest.mark.parametrize("primary", [CV_SERIES, METRON_ISSUE]) def test_primary_identity_must_be_a_verified_issue(primary): with pytest.raises(ArchiveMetadataRenderError, match="invalid_primary_identity"): diff --git a/tests/unit/test_import_comicinfo_enrichment.py b/tests/unit/test_import_comicinfo_enrichment.py index 6fa5a4d4..74f4e1f3 100644 --- a/tests/unit/test_import_comicinfo_enrichment.py +++ b/tests/unit/test_import_comicinfo_enrichment.py @@ -4,7 +4,7 @@ import threading from datetime import UTC, date, datetime from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, call +from unittest.mock import ANY, AsyncMock, call import pytest from sqlalchemy import select @@ -52,12 +52,17 @@ async def test_comicinfo_prefetch_chunks_provider_batches( monkeypatch.setattr( enrichment_module, "_load_pending_imported_file_ids", - AsyncMock(return_value=[]), + AsyncMock(return_value=[11, 12, 13]), ) monkeypatch.setattr( enrichment_module, "_load_pending_issue_cv_ids", - AsyncMock(return_value=[101, 102, 103]), + AsyncMock(side_effect=[[101, 102], [103]]), + ) + monkeypatch.setattr( + enrichment_module, + "_prepare_pending_imported_file_with_retry", + AsyncMock(return_value=None), ) monkeypatch.setattr(enrichment_module, "wait_for_comicinfo_turn", AsyncMock()) @@ -74,6 +79,10 @@ async def test_comicinfo_prefetch_chunks_provider_batches( call([101, 102]), call([103]), ] + assert enrichment_module._load_pending_issue_cv_ids.await_args_list == [ + call(ANY, job_id=7, imported_file_ids=[11, 12]), + call(ANY, job_id=7, imported_file_ids=[13]), + ] @pytest.mark.asyncio diff --git a/tests/unit/test_import_file_execution.py b/tests/unit/test_import_file_execution.py index 73587c13..31f59eb2 100644 --- a/tests/unit/test_import_file_execution.py +++ b/tests/unit/test_import_file_execution.py @@ -1701,10 +1701,12 @@ async def _register_file( assert library_file.has_comicinfo is True @pytest.mark.asyncio + @pytest.mark.parametrize("has_comicvine_id", [True, False]) async def test_import_marks_deferred_comicinfo_enrichment_after_file_placement( self, db_session: AsyncSession, tmp_path: Path, + has_comicvine_id: bool, ) -> None: from datetime import UTC, datetime @@ -1719,6 +1721,8 @@ async def test_import_marks_deferred_comicinfo_enrichment_after_file_placement( prepared_path.write_text("prepared") final_path = tmp_path / "library" / "Batman (2016) #001.cbz" job.update_embedded_comicinfo_from_match = True + if not has_comicvine_id: + issues[0].comicvine_id = None await db_session.flush() async def _build_payload( @@ -1740,7 +1744,8 @@ async def _register_file( confidence: MatchConfidence, **kwargs: object, ) -> LibraryFile: - assert kwargs["comicinfo_payload"] == {"Series": "Batman", "Number": "1"} + assert kwargs["comicinfo_payload"] is None + assert kwargs["update_embedded_comicinfo_from_match"] is False library_file = LibraryFile( file_path=str(final_path), file_name=final_path.name, @@ -3403,7 +3408,7 @@ async def test_mylar_in_place_execution_revalidates_scan_selected_root( assert (comic.read_bytes(), comic.stat().st_mtime_ns, comic.stat().st_mode) == before @pytest.mark.asyncio - async def test_import_execution_disables_nested_normalization_but_keeps_metadata_update( + async def test_import_execution_defers_cbz_metadata_without_nested_normalization( self, db_session: AsyncSession, ) -> None: @@ -3427,7 +3432,10 @@ async def test_import_execution_disables_nested_normalization_but_keeps_metadata call_kwargs = mock_register.call_args_list[0].kwargs assert call_kwargs.get("normalize_to_cbz") is False - assert call_kwargs.get("update_embedded_comicinfo_from_match") is True + assert call_kwargs.get("update_embedded_comicinfo_from_match") is False + assert call_kwargs.get("comicinfo_payload") is None + await db_session.refresh(_imp_files[0]) + assert _imp_files[0].diagnostics["comicinfo_enrichment"]["status"] == "pending" @pytest.mark.asyncio async def test_import_execution_skips_embedded_metadata_when_pdf_safety_fallback_triggers( From 80da37bc6dedb8413c86fd4571c123a586c6955a Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 18:19:00 -0700 Subject: [PATCH 81/97] fix(metadata): guard provider rollout and search loading state --- src/pullbox/config.py | 2 ++ src/pullbox/services/import_file_execution.py | 4 +++- src/pullbox/services/import_service.py | 12 ++++++++-- .../partials/add_series_results.html | 2 +- tests/conftest.py | 13 +++++++++++ tests/e2e/test_metadata_series_search.py | 22 ++++++++++++++++++- .../test_import_metadata_enrichment.py | 2 ++ .../test_import_metadata_retry.py | 2 ++ tests/unit/test_import_file_execution.py | 13 +++++++---- tests/unit/test_import_service.py | 22 +++++++++++++++++++ 10 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/pullbox/config.py b/src/pullbox/config.py index b3719515..2b967fbc 100644 --- a/src/pullbox/config.py +++ b/src/pullbox/config.py @@ -105,6 +105,8 @@ class PullboxSettings(BaseSettings): # ── ComicVine ────────────────────────────────────────────────────── comicvine_api_key: str = "" metadata_gcd_api_v2_enabled: bool = False + # Keep paired archive publication opt-in until its import workflow is qualified. + metadata_paired_import_writer_enabled: bool = False comicvine_rate_limit: int = 200 # Requests per hour # ── Import Debug ─────────────────────────────────────────────────── diff --git a/src/pullbox/services/import_file_execution.py b/src/pullbox/services/import_file_execution.py index fd05adc7..b973b8c9 100644 --- a/src/pullbox/services/import_file_execution.py +++ b/src/pullbox/services/import_file_execution.py @@ -17,6 +17,7 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.orm import joinedload +from pullbox.config import get_settings from pullbox.core.exceptions import ( ConfigurationError, ImportDestinationValidationError, @@ -1271,7 +1272,8 @@ async def report_current_file( getattr(prepared, "skip_embedded_comicinfo", False) ) defer_paired_metadata = ( - defer_comicinfo_enrichment + get_settings().metadata_paired_import_writer_enabled + and defer_comicinfo_enrichment and effective_embedded_comicinfo and move_to_library and Path(prepared.registration_source).suffix.casefold() in {".cbz", ".zip"} diff --git a/src/pullbox/services/import_service.py b/src/pullbox/services/import_service.py index 35fdf456..54705543 100644 --- a/src/pullbox/services/import_service.py +++ b/src/pullbox/services/import_service.py @@ -775,7 +775,11 @@ def schedule_comicinfo_enrichment( apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, - metadata_writer=partial(write_imported_archive_metadata, session_factory), + metadata_writer=( + partial(write_imported_archive_metadata, session_factory) + if get_settings().metadata_paired_import_writer_enabled + else None + ), ) def schedule_story_arc_sync(self) -> None: @@ -799,7 +803,11 @@ async def recover_pending_comicinfo_enrichment( apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, - metadata_writer=partial(write_imported_archive_metadata, session_factory), + metadata_writer=( + partial(write_imported_archive_metadata, session_factory) + if get_settings().metadata_paired_import_writer_enabled + else None + ), ) async def recover_pending_catalog_hydration( diff --git a/src/pullbox/ui/templates/partials/add_series_results.html b/src/pullbox/ui/templates/partials/add_series_results.html index b32f44a6..f5c573d7 100644 --- a/src/pullbox/ui/templates/partials/add_series_results.html +++ b/src/pullbox/ui/templates/partials/add_series_results.html @@ -5,7 +5,7 @@ data-testid="add-series-results" class="add-series-results-shell space-y-3" > - {{ comicvine_search_loading("add-series-results-loading", "add-series-results-loading", label="Searching " ~ search_source_label) }} + {{ comicvine_search_loading("add-series-results-loading", "add-series-results-loading", label="Searching metadata sources") }} {% if is_preview_search and search_query %}
None: _configure_worker_runtime_environment() +@pytest.fixture +def paired_import_writer_setting(monkeypatch, request): + from pullbox.config import get_settings + + enabled = getattr(request, "param", True) + monkeypatch.setenv("PULLBOX_METADATA_PAIRED_IMPORT_WRITER_ENABLED", str(enabled)) + get_settings.cache_clear() + try: + yield enabled + finally: + get_settings.cache_clear() + + # ── Event loop isolation ───────────────────────────────────────────── # The e2e tests run a live uvicorn server in a daemon thread. That # thread's event loop can leak into the main thread's C-level diff --git a/tests/e2e/test_metadata_series_search.py b/tests/e2e/test_metadata_series_search.py index ec0224f1..5159e4ce 100644 --- a/tests/e2e/test_metadata_series_search.py +++ b/tests/e2e/test_metadata_series_search.py @@ -66,6 +66,26 @@ async def close(self): return calls +def test_source_switch_does_not_name_the_previous_provider_while_loading( + authed_page, seeded_server, metron_search +): + page = authed_page + page.goto(f"{seeded_server}/series/add?q=Swamp+Thing&source=comicvine_api") + pending = [] + page.route("**/series/add?**source=metron_api**", lambda route: pending.append(route)) + try: + page.get_by_test_id("add-series-source-select").get_by_role("button").click() + page.get_by_role("option", name="Metron", exact=True).click() + loader = page.locator("#add-series-results-loading") + expect(loader).to_be_visible() + expect(loader).to_contain_text("Searching metadata sources") + expect(loader).not_to_contain_text("ComicVine API") + finally: + for route in pending: + route.abort() + page.unroute("**/series/add?**source=metron_api**") + + @pytest.mark.parametrize("theme,width", [("light", 1280), ("dark", 1280), ("light", 320)]) def test_source_picker_pagination_and_result_action( authed_page, seeded_server, monkeypatch, metron_search, theme, width, browser_name @@ -73,7 +93,7 @@ def test_source_picker_pagination_and_result_action( page = authed_page page.set_viewport_size({"width": width, "height": 900}) page.emulate_media(reduced_motion="reduce") - query = f"Search example {theme} {width}" + query = f"Search example {browser_name} {theme} {width}" page.goto(f"{seeded_server}/series/add?q={query}&sort=title") page.evaluate("theme => applyTheme(theme)", theme) expect(page.get_by_test_id("add-series-result-card")).to_have_count(20) diff --git a/tests/integration/metadata_identity/test_import_metadata_enrichment.py b/tests/integration/metadata_identity/test_import_metadata_enrichment.py index e4f081fe..fe60dc05 100644 --- a/tests/integration/metadata_identity/test_import_metadata_enrichment.py +++ b/tests/integration/metadata_identity/test_import_metadata_enrichment.py @@ -30,6 +30,8 @@ from pullbox.services.import_service import ImportService from tests.integration.metadata_identity.test_import_archive_publication import owned, rollback +pytestmark = pytest.mark.usefixtures("paired_import_writer_setting") + def import_service(): service = ImportService(MagicMock(), MagicMock(), MagicMock()) diff --git a/tests/integration/metadata_identity/test_import_metadata_retry.py b/tests/integration/metadata_identity/test_import_metadata_retry.py index 7f732530..31e8a223 100644 --- a/tests/integration/metadata_identity/test_import_metadata_retry.py +++ b/tests/integration/metadata_identity/test_import_metadata_retry.py @@ -12,6 +12,8 @@ from tests.integration.metadata_identity.test_import_archive_publication import owned, publish from tests.integration.metadata_identity.test_import_metadata_enrichment import import_service +pytestmark = pytest.mark.usefixtures("paired_import_writer_setting") + async def fail_for_root(factory, plan, ids): async with factory.begin() as session: diff --git a/tests/unit/test_import_file_execution.py b/tests/unit/test_import_file_execution.py index 31f59eb2..afb28405 100644 --- a/tests/unit/test_import_file_execution.py +++ b/tests/unit/test_import_file_execution.py @@ -1701,6 +1701,7 @@ async def _register_file( assert library_file.has_comicinfo is True @pytest.mark.asyncio + @pytest.mark.usefixtures("paired_import_writer_setting") @pytest.mark.parametrize("has_comicvine_id", [True, False]) async def test_import_marks_deferred_comicinfo_enrichment_after_file_placement( self, @@ -3408,10 +3409,13 @@ async def test_mylar_in_place_execution_revalidates_scan_selected_root( assert (comic.read_bytes(), comic.stat().st_mtime_ns, comic.stat().st_mode) == before @pytest.mark.asyncio + @pytest.mark.parametrize("paired_import_writer_setting", [False, True], indirect=True) async def test_import_execution_defers_cbz_metadata_without_nested_normalization( self, db_session: AsyncSession, + paired_import_writer_setting: bool, ) -> None: + paired_enabled = paired_import_writer_setting job, _imp_series, _imp_files, series, _issues = await _setup_full_scenario( db_session, num_issues=1 ) @@ -3432,10 +3436,11 @@ async def test_import_execution_defers_cbz_metadata_without_nested_normalization call_kwargs = mock_register.call_args_list[0].kwargs assert call_kwargs.get("normalize_to_cbz") is False - assert call_kwargs.get("update_embedded_comicinfo_from_match") is False - assert call_kwargs.get("comicinfo_payload") is None - await db_session.refresh(_imp_files[0]) - assert _imp_files[0].diagnostics["comicinfo_enrichment"]["status"] == "pending" + assert call_kwargs.get("update_embedded_comicinfo_from_match") is (not paired_enabled) + if paired_enabled: + assert call_kwargs.get("comicinfo_payload") is None + await db_session.refresh(_imp_files[0]) + assert _imp_files[0].diagnostics["comicinfo_enrichment"]["status"] == "pending" @pytest.mark.asyncio async def test_import_execution_skips_embedded_metadata_when_pdf_safety_fallback_triggers( diff --git a/tests/unit/test_import_service.py b/tests/unit/test_import_service.py index 8ecf475c..e964898d 100644 --- a/tests/unit/test_import_service.py +++ b/tests/unit/test_import_service.py @@ -106,6 +106,28 @@ def _make_service( ) +@pytest.mark.asyncio +async def test_unqualified_paired_writer_is_not_enabled_by_default(monkeypatch) -> None: + monkeypatch.delenv("PULLBOX_METADATA_PAIRED_IMPORT_WRITER_ENABLED", raising=False) + get_settings.cache_clear() + service = _make_service() + factory = MagicMock() + try: + with ( + patch("pullbox.services.import_service.schedule_import_comicinfo_enrichment") as queue, + patch( + "pullbox.services.import_service.run_pending_import_comicinfo_enrichment", + new_callable=AsyncMock, + ) as recover, + ): + service.schedule_comicinfo_enrichment(factory, job_id=1) + await service.recover_pending_comicinfo_enrichment(factory) + assert queue.call_args.kwargs["metadata_writer"] is None + assert recover.call_args.kwargs["metadata_writer"] is None + finally: + get_settings.cache_clear() + + @pytest.mark.asyncio @pytest.mark.parametrize( ("state", "schedule_sync", "schedule_enrichment"), From 4ff39c2512888de2850a9b503926d6b40e3ceb0b Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Tue, 29 Sep 2026 18:58:16 -0700 Subject: [PATCH 82/97] fix(metadata): show Metron covers in series search and preview --- src/pullbox/api/middleware.py | 3 +- src/pullbox/api/v1/metadata_sources.py | 16 ++- src/pullbox/schemas/metadata_sources.py | 4 + .../services/metadata_series_adoption.py | 3 + .../services/metadata_series_artwork.py | 38 +++++++ .../services/metadata_series_preview.py | 5 + src/pullbox/ui/series_routes.py | 3 + .../components/comicvine_result_cover.html | 23 +++- .../ui/templates/pages/add_series.html | 95 +++++++++++++++- .../partials/search_result_card.html | 4 +- tests/api/test_metadata_sources_api.py | 15 +++ tests/e2e/test_metadata_series_search.py | 107 ++++++++++++++++++ .../metadata_identity/test_series_adoption.py | 18 +++ .../test_metadata_series_adoption_fetch.py | 11 ++ tests/unit/test_metadata_series_preview.py | 50 +++++++- tests/unit/test_security_headers.py | 3 +- 16 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 src/pullbox/services/metadata_series_artwork.py diff --git a/src/pullbox/api/middleware.py b/src/pullbox/api/middleware.py index 3c643ca6..f548082f 100644 --- a/src/pullbox/api/middleware.py +++ b/src/pullbox/api/middleware.py @@ -428,7 +428,8 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: " "https://comicvine.gamespot.com https://*.gamespot.com " - "https://s3.amazonaws.com; " + "https://s3.amazonaws.com https://metron.cloud/media/ " + "https://static.metron.cloud/media/; " "connect-src 'self'; " "font-src 'self'; " "frame-ancestors 'none'" diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py index 718413ee..ef58e2e6 100644 --- a/src/pullbox/api/v1/metadata_sources.py +++ b/src/pullbox/api/v1/metadata_sources.py @@ -19,11 +19,13 @@ SeriesDiscoveryQuery, SeriesDiscoveryRead, SeriesIssuePageQuery, + SeriesIssuePageRead, SeriesPreviewRead, SourceDescriptor, SourcePolicyRead, SourcePolicyWrite, SourcePriorityWrite, + SourceStatus, SourceTestRead, StoryArcDiscoveryQuery, StoryArcDiscoveryRead, @@ -35,6 +37,7 @@ from pullbox.services.metadata_arc_preview import preview_source_arc from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache +from pullbox.services.metadata_series_artwork import representative_series_cover from pullbox.services.metadata_series_preview import preview_series_folder, preview_source_series from pullbox.services.metadata_source_status import deferred_work, source_status from pullbox.services.metadata_sources import ( @@ -161,10 +164,10 @@ async def preview_series( return result -@router.post("/series/issues", response_model=MetadataFetch[MetadataPage[ProviderIssueRead]]) +@router.post("/series/issues", response_model=SeriesIssuePageRead) async def series_issues( body: SeriesIssuePageQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings -) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: +) -> SeriesIssuePageRead: runtime = await load_source_runtime( session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled ) @@ -180,7 +183,14 @@ async def series_issues( read_cache=source_read_cache(session), ).issues(body.source, body.external_id, page=body.page) await _require_source_revision(session, body.source, body.source_revision) - return result + return SeriesIssuePageRead( + **result.model_dump(), + series_cover_url=representative_series_cover( + body.source, body.external_id, result.data.results + ) + if body.page == 1 and result.status is SourceStatus.OK and result.data is not None + else None, + ) @router.get("/sources", response_model=list[SourceDescriptor]) diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index c2ad2a61..6bae7ee6 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -278,6 +278,10 @@ class SeriesIssuePageQuery(SeriesPreviewQuery): source_revision: int = Field(ge=0, lt=2**63, strict=True) +class SeriesIssuePageRead(MetadataFetch[MetadataPage[ProviderIssueRead]]): + series_cover_url: str | None = None + + class SeriesPreviewRead(BaseModel): source: MetadataSource external_id: str diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index b51513af..839be3da 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -52,6 +52,7 @@ from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_identity_attachment import attach_verified_identities from pullbox.services.metadata_identity_review import record_identity_observation +from pullbox.services.metadata_series_artwork import with_representative_cover from pullbox.services.metadata_service import MetadataService, classify_issue_metadata from pullbox.services.metadata_source_reads import source_id from pullbox.services.metadata_sources import read_source_policies @@ -178,6 +179,8 @@ async def fetch_source_series_bundle( raise SeriesAdoptionError( "The issue catalog exceeds the interactive add limit." ) + if page == 1: + profile = with_representative_cover(profile, current.results) for issue in current.results: if source is MetadataSource.COMICVINE_LOCAL and ( profile.source_updated_at is None diff --git a/src/pullbox/services/metadata_series_artwork.py b/src/pullbox/services/metadata_series_artwork.py new file mode 100644 index 00000000..a7a1268a --- /dev/null +++ b/src/pullbox/services/metadata_series_artwork.py @@ -0,0 +1,38 @@ +"""Representative Metron series artwork from an already-read first issue page.""" + +from collections.abc import Sequence + +from pullbox.core.metadata_identity import MetadataSource +from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderSeriesRead +from pullbox.services.provider_artwork import allowed_artwork_url + + +def representative_series_cover( + source: MetadataSource, external_id: str, issues: Sequence[ProviderIssueRead] +) -> str | None: + if source is not MetadataSource.METRON_API or len(issues) > 100: + return None + if any( + issue.source is not source + or issue.identity_namespace is not source.identity_namespace + or issue.series_external_id != external_id + for issue in issues + ): + return None + return next( + ( + issue.image_url + for issue in issues + if issue.image_url and allowed_artwork_url(issue.image_url) + ), + None, + ) + + +def with_representative_cover( + profile: ProviderSeriesRead, issues: Sequence[ProviderIssueRead] +) -> ProviderSeriesRead: + if profile.image_url: + return profile + cover = representative_series_cover(profile.source, profile.external_id, issues) + return profile.model_copy(update={"image_url": cover}) if cover else profile diff --git a/src/pullbox/services/metadata_series_preview.py b/src/pullbox/services/metadata_series_preview.py index 1b0d09b7..e24a4c31 100644 --- a/src/pullbox/services/metadata_series_preview.py +++ b/src/pullbox/services/metadata_series_preview.py @@ -18,6 +18,7 @@ SourceStatus, ) from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_series_artwork import with_representative_cover from pullbox.services.metadata_source_reads import source_id @@ -64,6 +65,10 @@ async def preview_source_series( preview.series = await registry.series(source, identifier) if preview.series.status is SourceStatus.OK: preview.issues = await registry.issues(source, identifier) + if preview.series.data is not None and preview.issues.data is not None: + preview.series.data = with_representative_cover( + preview.series.data, preview.issues.data.results + ) except TimeoutError: if preview.series.status is SourceStatus.NOT_QUERIED: preview.series = MetadataFetch(status=SourceStatus.TIMEOUT) diff --git a/src/pullbox/ui/series_routes.py b/src/pullbox/ui/series_routes.py index 325fc5f8..f5c8469c 100644 --- a/src/pullbox/ui/series_routes.py +++ b/src/pullbox/ui/series_routes.py @@ -687,6 +687,9 @@ async def load_add_series_search_context( runtime = await load_source_runtime( session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled ) + base_context["search_source_revisions"] = { + item.policy.source.value: item.policy.revision for item in runtime + } naming_config = await _system_config_values( session, ( diff --git a/src/pullbox/ui/templates/components/comicvine_result_cover.html b/src/pullbox/ui/templates/components/comicvine_result_cover.html index b5ff6471..8b8eaff5 100644 --- a/src/pullbox/ui/templates/components/comicvine_result_cover.html +++ b/src/pullbox/ui/templates/components/comicvine_result_cover.html @@ -1,5 +1,12 @@ -{% macro comicvine_result_cover(cover_url, title, href=none, link_testid=none) -%} -
+{% macro comicvine_result_cover(cover_url, title, href=none, link_testid=none, metron_id=none, source_revision=none) -%} +
{% if href %} {% endif %} - {% if cover_url %} + {% if metron_id and source_revision is not none %} + {{ title }} + + {% elif cover_url %} {{ title }} -
diff --git a/tests/e2e/test_series_metadata_links.py b/tests/e2e/test_series_metadata_links.py index 6926290c..8f2cade5 100644 --- a/tests/e2e/test_series_metadata_links.py +++ b/tests/e2e/test_series_metadata_links.py @@ -9,6 +9,115 @@ pytestmark = pytest.mark.e2e +@pytest.mark.parametrize("theme,width", [("light", 1280), ("dark", 390)]) +def test_empty_provider_search_is_not_an_outage_and_can_be_retried( + authed_page, seeded_server, theme, width +): + page = authed_page + page.set_viewport_size({"width": width, "height": 1000}) + title = "Batman: The Dark Knight: Golden Dawn" + page.route( + "**/api/v1/series/1/metadata-links", + lambda route: route.fulfill( + json={ + "current": {"title": title}, + "identities": [], + "sources": [{"source": "metron_api", "label": "Metron"}], + } + ), + ) + searches = [] + candidate = { + "source": "metron_api", + "identity_namespace": "metron", + "external_id": "123", + "title": "Batman: The Dark Knight", + "year_start": 2012, + "publisher": "DC", + "issue_count": 1, + } + + def search(route): + searches.append(route.request.post_data_json) + matched = len(searches) == 2 + route.fulfill( + json={ + "results": [candidate] if matched else [], + "sources": [ + { + "source": "metron_api", + "status": "ok" if matched else "empty", + "total": 1 if matched else 0, + "next_offset": None, + } + ], + } + ) + + page.route("**/api/v1/metadata/search", search) + SeriesDetailPage(page, seeded_server).goto(1) + page.evaluate("theme => applyTheme(theme)", theme) + page.get_by_role("button", name="Link provider", exact=True).click() + dialog = page.get_by_role("dialog", name="Link metadata provider", exact=True) + query = dialog.get_by_label("Series title", exact=True) + expect(query).to_have_value(title) + button = dialog.get_by_role("button", name="Search provider", exact=True) + button.click() + empty = dialog.get_by_text("No matches found. Try a shorter title or another provider.") + expect(empty).to_be_visible() + expect(dialog.get_by_role("alert")).to_be_hidden() + expect(button).to_be_enabled() + expect(query).to_have_value(title) + assert_no_axe_violations( + page, + name=f"series-provider-empty-{theme}-{width}", + include=['[aria-labelledby="series-link-title"]'], + ) + assert len(searches) == 1 + query.fill("Golden Dawn") + query.press("Enter") + expect(dialog.get_by_role("button", name="Review Batman: The Dark Knight")).to_be_visible() + expect(empty).to_be_hidden() + expect(dialog.get_by_role("alert")).to_be_hidden() + assert searches[1]["query"] == "Golden Dawn" + # A subsequent empty response clears the previous candidates without closing the dialog. + button.click() + expect(empty).to_be_visible() + expect(dialog.get_by_role("button", name="Review Batman: The Dark Knight")).to_be_hidden() + expect(dialog.get_by_role("alert")).to_be_hidden() + expect(dialog.get_by_role("button", name="Link this series")).to_be_hidden() + assert len(searches) == 3 + + +@pytest.mark.parametrize("status", ["unavailable", "authentication_failed", "rate_limited"]) +def test_provider_search_failures_are_not_shown_as_no_matches(authed_page, seeded_server, status): + page = authed_page + page.route( + "**/api/v1/series/1/metadata-links", + lambda route: route.fulfill( + json={ + "current": {"title": "Batman"}, + "identities": [], + "sources": [{"source": "metron_api", "label": "Metron"}], + } + ), + ) + page.route( + "**/api/v1/metadata/search", + lambda route: route.fulfill( + json={"results": [], "sources": [{"source": "metron_api", "status": status}]} + ), + ) + SeriesDetailPage(page, seeded_server).goto(1) + page.get_by_role("button", name="Link provider", exact=True).click() + dialog = page.get_by_role("dialog", name="Link metadata provider", exact=True) + button = dialog.get_by_role("button", name="Search provider", exact=True) + button.click() + expect(dialog.get_by_role("alert")).to_be_visible() + expect(dialog.get_by_text("No matches found.", exact=False)).to_be_hidden() + expect(button).to_be_enabled() + + @pytest.mark.parametrize("theme,width", [("light", 1280), ("dark", 390)]) def test_provider_review_search_pagination_and_confirmation( authed_page, seeded_server, theme, width diff --git a/tests/unit/test_standalone_shell.py b/tests/unit/test_standalone_shell.py index a634af5d..057ef55b 100644 --- a/tests/unit/test_standalone_shell.py +++ b/tests/unit/test_standalone_shell.py @@ -10,7 +10,13 @@ @pytest.mark.parametrize( "script", - ["story-arc-preview.js", "story-arc-detail.js", "series-rescan.js", "metadata-sources.js"], + [ + "story-arc-preview.js", + "story-arc-detail.js", + "series-rescan.js", + "metadata-sources.js", + "series-metadata-links.js", + ], ) def test_story_arc_controller_change_invalidates_main_shell_cache( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script: str From 524631612a3982f126cf57fff9b6a2c19ee2a190 Mon Sep 17 00:00:00 2001 From: Adam Hernandez Date: Thu, 1 Oct 2026 11:06:50 -0700 Subject: [PATCH 86/97] feat(metadata): review unsupported GCD catalog entries before add --- ...5b6c7d890_add_series_catalog_exclusions.py | 34 +++ src/pullbox/api/v1/metadata_sources.py | 22 +- src/pullbox/api/v1/series.py | 2 + src/pullbox/models/series.py | 3 + src/pullbox/schemas/metadata_sources.py | 19 ++ src/pullbox/schemas/series.py | 3 + .../services/metadata_catalog_review.py | 114 ++++++++++ .../services/metadata_series_adoption.py | 43 +++- .../services/metadata_series_preview.py | 18 ++ .../services/metadata_series_refresh.py | 2 + .../services/metadata_series_refresh_state.py | 9 +- src/pullbox/ui/static/css/tailwind.css | 2 +- .../ui/templates/pages/add_series.html | 37 +++- .../ui/templates/pages/series_detail.html | 14 ++ tests/api/test_gcd_local.py | 196 ++++++++++++++++++ tests/e2e/test_source_series_add.py | 90 ++++++++ .../test_catalog_exclusions.py | 96 +++++++++ tests/integration/test_migrations.py | 3 +- 18 files changed, 683 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py create mode 100644 src/pullbox/services/metadata_catalog_review.py create mode 100644 tests/integration/metadata_identity/test_catalog_exclusions.py diff --git a/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py b/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py new file mode 100644 index 00000000..c478d702 --- /dev/null +++ b/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py @@ -0,0 +1,34 @@ +"""Retain explicit GCD Add exclusions independently of issue ownership.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "g4a5b6c7d890" +down_revision = "f3z4a5b6c789" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "series", sa.Column("catalog_exclusions", sa.JSON(), nullable=False, server_default="[]") + ) + + +def downgrade() -> None: + table = sa.table("series", sa.column("catalog_exclusions", sa.JSON())) + if ( + op.get_bind() + .execute( + sa.select(table.c.catalog_exclusions) + .where(sa.cast(table.c.catalog_exclusions, sa.String()) != "[]") + .limit(1) + ) + .first() + is not None + ): + raise RuntimeError( + "Retained catalog exclusions prevent downgrade; preserve the review first." + ) + op.drop_column("series", "catalog_exclusions") diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py index ca33a9c9..67e229f3 100644 --- a/src/pullbox/api/v1/metadata_sources.py +++ b/src/pullbox/api/v1/metadata_sources.py @@ -39,6 +39,7 @@ from pullbox.services.metadata_arc_preview import preview_source_arc from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache +from pullbox.services.metadata_series_adoption import SeriesAdoptionError from pullbox.services.metadata_series_artwork import representative_series_cover from pullbox.services.metadata_series_preview import preview_series_folder, preview_source_series from pullbox.services.metadata_source_status import deferred_work, source_status @@ -142,15 +143,18 @@ async def preview_series( session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled ) await session.rollback() - result = await preview_source_series( - MetadataSourceRegistry( - runtime, - gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, - read_cache=source_read_cache(session), - ), - body.source, - body.external_id, - ) + try: + result = await preview_source_series( + MetadataSourceRegistry( + runtime, + gcd_api_enabled=settings.metadata_gcd_api_v2_enabled, + read_cache=source_read_cache(session), + ), + body.source, + body.external_id, + ) + except SeriesAdoptionError as exc: + raise HTTPException(409, str(exc)) from exc await _require_source_revision(session, body.source, result.source_revision) if body.library_root_id is not None: root = await _preview_root(session, body.library_root_id) diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py index d22dce1b..9814ec52 100644 --- a/src/pullbox/api/v1/series.py +++ b/src/pullbox/api/v1/series.py @@ -32,6 +32,7 @@ SourceSeriesCreate, ) from pullbox.services.cover_url_service import build_series_cover_url +from pullbox.services.metadata_catalog_review import approve_catalog_review from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_read_cache import source_read_cache from pullbox.services.metadata_series_add import source_series_add_transaction @@ -407,6 +408,7 @@ async def add_series( body.external_id, source_revision=body.source_revision, ) + bundle = approve_catalog_review(bundle, body.catalog_review_token) # This command owns commit/cleanup; serialize before committing so # a failed response cannot leave an unexpected library addition. async with source_series_add_transaction( diff --git a/src/pullbox/models/series.py b/src/pullbox/models/series.py index 9caa638e..0d65394c 100644 --- a/src/pullbox/models/series.py +++ b/src/pullbox/models/series.py @@ -116,6 +116,9 @@ class Series(Base, IdentityMixin, TimestampMixin): issue_catalog_last_synced_at: Mapped[datetime | None] = mapped_column(UTCDateTime) issue_catalog_last_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime) issue_catalog_error: Mapped[str | None] = mapped_column(Text) + catalog_exclusions: Mapped[list[dict[str, str]]] = mapped_column( + JSON, default=list, server_default="[]" + ) # Monitoring — binary on/off, controls auto-search and issue status transitions monitored: Mapped[bool] = mapped_column(default=False, index=True) diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py index 6bae7ee6..b06e82ff 100644 --- a/src/pullbox/schemas/metadata_sources.py +++ b/src/pullbox/schemas/metadata_sources.py @@ -282,6 +282,24 @@ class SeriesIssuePageRead(MetadataFetch[MetadataPage[ProviderIssueRead]]): series_cover_url: str | None = None +class CatalogExcludedIssue(BaseModel): + """An exact provider entry explicitly left out, never an issue identity claim.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source: Literal[MetadataSource.GCD_LOCAL] + series_external_id: str = Field(min_length=1, max_length=255) + external_id: str = Field(min_length=1, max_length=255) + issue_number_text: str = Field(max_length=100) + + +class CatalogReviewRead(BaseModel): + token: str + total: int + supported_count: int + excluded: tuple[CatalogExcludedIssue, ...] + + class SeriesPreviewRead(BaseModel): source: MetadataSource external_id: str @@ -289,6 +307,7 @@ class SeriesPreviewRead(BaseModel): series: MetadataFetch[ProviderSeriesRead] issues: MetadataFetch[MetadataPage[ProviderIssueRead]] folder_preview: str | None = None + catalog_review: CatalogReviewRead | None = None class StoryArcPreviewQuery(SeriesPreviewQuery): diff --git a/src/pullbox/schemas/series.py b/src/pullbox/schemas/series.py index c8b67319..4329bd58 100644 --- a/src/pullbox/schemas/series.py +++ b/src/pullbox/schemas/series.py @@ -11,6 +11,7 @@ SeriesStatusOverride, SeriesType, ) +from pullbox.schemas.metadata_sources import CatalogExcludedIssue class SeriesCreate(BaseModel): @@ -48,6 +49,7 @@ class SourceSeriesCreate(BaseModel): source_revision: int = Field(..., ge=0, strict=True) library_root_id: int | None = Field(None, gt=0) search_on_add: bool | None = None + catalog_review_token: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") @model_validator(mode="after") def canonical_identity(self) -> "SourceSeriesCreate": @@ -89,6 +91,7 @@ class SeriesResponse(BaseModel): issue_catalog_last_synced_at: datetime | None = None issue_catalog_last_checked_at: datetime | None = None issue_catalog_error: str | None = None + catalog_exclusions: list[CatalogExcludedIssue] = Field(default_factory=list) comicvine_url: str | None = None monitored: bool metadata_last_refreshed: datetime | None = None diff --git a/src/pullbox/services/metadata_catalog_review.py b/src/pullbox/services/metadata_catalog_review.py new file mode 100644 index 00000000..b9fa1821 --- /dev/null +++ b/src/pullbox/services/metadata_catalog_review.py @@ -0,0 +1,114 @@ +"""Explicit Add exclusions for unsupported GCD designations, not identity guessing.""" + +import hashlib +import json +from dataclasses import replace + +from pullbox.core.issue_numbers import parse_issue_number_text +from pullbox.core.metadata_identity import MetadataSource +from pullbox.schemas.metadata_sources import ( + CatalogExcludedIssue, + CatalogReviewRead, + ProviderIssueRead, +) +from pullbox.services.metadata_series_adoption import ( + SeriesAdoptionError, + SourceIssueBatch, + SourceSeriesBundle, + _validate_bundle, + _validate_issue_batch, +) + + +def _entry(issue: ProviderIssueRead) -> CatalogExcludedIssue: + return CatalogExcludedIssue( + source=MetadataSource.GCD_LOCAL, + series_external_id=issue.series_external_id, + external_id=issue.external_id, + issue_number_text=issue.issue_number_text, + ) + + +def catalog_review(bundle: SourceSeriesBundle) -> CatalogReviewRead | None: + if bundle.series.source is not MetadataSource.GCD_LOCAL: + return None + supported, excluded = [], [] + for issue in bundle.issues: + try: + parse_issue_number_text(issue.issue_number_text) + except ValueError: + excluded.append(issue) + else: + supported.append(issue) + if not excluded: + _validate_bundle(bundle) + return None + # Counts, identity, parent and duplicate checks still apply to every entry. + if supported: + _validate_bundle(replace(bundle, issues=tuple(supported), excluded_issues=tuple(excluded))) + else: + _validate_issue_batch( + SourceIssueBatch( + bundle.series.source, + bundle.series.external_id, + bundle.issues, + bundle.source_revision, + ), + allow_unsupported=True, + ) + payload = { + "series": bundle.series.model_dump(mode="json"), + "source_revision": bundle.source_revision, + "issues": [item.model_dump(mode="json") for item in bundle.issues], + } + # Content fingerprint binds consent to the fetched catalog, not authentication. + token = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return CatalogReviewRead( + token=token, + total=bundle.catalog_total, + supported_count=len(supported), + excluded=tuple(_entry(item) for item in excluded), + ) + + +def approve_catalog_review(bundle: SourceSeriesBundle, token: str | None) -> SourceSeriesBundle: + review = catalog_review(bundle) + if review is None: + if token is not None: + raise SeriesAdoptionError("The catalog changed. Preview the series again.") + return bundle + if token != review.token or not review.supported_count: + raise SeriesAdoptionError( + "Review the unsupported catalog entries before adding this series." + ) + return apply_catalog_exclusions(bundle, review.excluded) + + +def apply_catalog_exclusions( + bundle: SourceSeriesBundle, saved: tuple[CatalogExcludedIssue, ...] +) -> SourceSeriesBundle: + relevant = tuple( + item + for item in saved + if item.source is bundle.series.source + and item.series_external_id == bundle.series.external_id + ) + if not relevant: + return bundle + by_id = {item.external_id: item for item in relevant} + excluded = tuple(item for item in bundle.issues if item.external_id in by_id) + if len(excluded) != len(relevant) or any( + _entry(item) != by_id[item.external_id] for item in excluded + ): + raise SeriesAdoptionError( + "Previously reviewed GCD entries changed. Review the catalog before retrying." + ) + result = replace( + bundle, + issues=tuple(item for item in bundle.issues if item.external_id not in by_id), + excluded_issues=excluded, + ) + _validate_bundle(result) + return result diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index bdcc2af9..f3a01d4c 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -37,7 +37,12 @@ MetadataValues, field_domain, ) -from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderSeriesRead, SourceStatus +from pullbox.schemas.metadata_sources import ( + CatalogExcludedIssue, + ProviderIssueRead, + ProviderSeriesRead, + SourceStatus, +) from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import ( MetadataBaselineWrite, @@ -103,6 +108,7 @@ class SourceSeriesBundle: source_revision: int catalog_total: int catalog_started_at: datetime | None = None + excluded_issues: tuple[ProviderIssueRead, ...] = () @dataclass(frozen=True) @@ -272,21 +278,37 @@ def _identities( def _validate_bundle(bundle: SourceSeriesBundle) -> list[tuple[float, str]]: profile = bundle.series _identities(profile, MetadataEntityKind.SERIES) + catalog = (*bundle.issues, *bundle.excluded_issues) if ( not profile.title.strip() or len(profile.title) > 500 - or len(bundle.issues) > 10000 + or len(catalog) > 10000 or type(bundle.catalog_total) is not int - or bundle.catalog_total != len(bundle.issues) - or (profile.issue_count is not None and profile.issue_count != len(bundle.issues)) + or bundle.catalog_total != len(catalog) + or (profile.issue_count is not None and profile.issue_count != len(catalog)) ): raise SeriesAdoptionError("The series profile or issue catalog is incomplete.") + if bundle.excluded_issues: + if profile.source is not MetadataSource.GCD_LOCAL or not bundle.issues: + raise SeriesAdoptionError("This catalog cannot be added with exclusions.") + for issue in bundle.excluded_issues: + try: + parse_issue_number_text(issue.issue_number_text) + except ValueError: + continue + raise SeriesAdoptionError("Supported issues cannot be omitted by catalog review.") + _validate_issue_batch( + SourceIssueBatch(profile.source, profile.external_id, catalog, bundle.source_revision), + allow_unsupported=True, + ) return _validate_issue_batch( SourceIssueBatch(profile.source, profile.external_id, bundle.issues, bundle.source_revision) ) -def _validate_issue_batch(batch: SourceIssueBatch) -> list[tuple[float, str]]: +def _validate_issue_batch( + batch: SourceIssueBatch, *, allow_unsupported: bool = False +) -> list[tuple[float, str]]: if ( type(batch.source_revision) is not int or batch.source_revision <= 0 @@ -306,6 +328,8 @@ def _validate_issue_batch(batch: SourceIssueBatch) -> list[tuple[float, str]]: try: number = parse_issue_number_text(issue.issue_number_text) except ValueError as exc: + if allow_unsupported and batch.source is MetadataSource.GCD_LOCAL: + continue raise SeriesAdoptionError( "This catalog contains an unsupported issue designation; no issues were added." ) from exc @@ -501,6 +525,15 @@ async def _adopt( ) ), issue_count=len(bundle.issues), + catalog_exclusions=[ + CatalogExcludedIssue( + source=MetadataSource.GCD_LOCAL, + series_external_id=issue.series_external_id, + external_id=issue.external_id, + issue_number_text=issue.issue_number_text, + ).model_dump(mode="json") + for issue in bundle.excluded_issues + ], monitored=monitored, publisher_id=publisher_id, metadata_source=_metadata_label(profile.source), diff --git a/src/pullbox/services/metadata_series_preview.py b/src/pullbox/services/metadata_series_preview.py index e24a4c31..1a84556a 100644 --- a/src/pullbox/services/metadata_series_preview.py +++ b/src/pullbox/services/metadata_series_preview.py @@ -13,11 +13,14 @@ from pullbox.models.series import Series, SeriesType from pullbox.schemas.metadata_sources import ( MetadataFetch, + MetadataPage, ProviderSeriesRead, SeriesPreviewRead, SourceStatus, ) +from pullbox.services.metadata_catalog_review import catalog_review from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_series_adoption import fetch_source_series_bundle from pullbox.services.metadata_series_artwork import with_representative_cover from pullbox.services.metadata_source_reads import source_id @@ -61,6 +64,21 @@ async def preview_source_series( issues=MetadataFetch(status=SourceStatus.NOT_QUERIED), ) try: + if source is MetadataSource.GCD_LOCAL: + bundle = await fetch_source_series_bundle( + registry, source, identifier, source_revision=preview.source_revision + ) + preview.series = MetadataFetch(status=SourceStatus.OK, data=bundle.series) + preview.issues = MetadataFetch( + status=SourceStatus.OK, + data=MetadataPage( + results=list(bundle.issues[:100]), + total=bundle.catalog_total, + next_page=2 if len(bundle.issues) > 100 else None, + ), + ) + preview.catalog_review = catalog_review(bundle) + return preview async with asyncio.timeout(registry.total_timeout): preview.series = await registry.series(source, identifier) if preview.series.status is SourceStatus.OK: diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index cfadf31a..0e0a7859 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -30,6 +30,7 @@ from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint +from pullbox.services.metadata_catalog_review import apply_catalog_exclusions from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_entity_values import apply_series_metadata_values from pullbox.services.metadata_issue_catalog import ( @@ -345,6 +346,7 @@ async def _catalog( source_revision=registry.runtime[source].policy.revision, profile=profiles.get(source), ) + bundle = apply_catalog_exclusions(bundle, state.catalog_exclusions) _validate_bundle(bundle) return bundle except SeriesCatalogCountError: diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py index e802873e..d01d039b 100644 --- a/src/pullbox/services/metadata_series_refresh_state.py +++ b/src/pullbox/services/metadata_series_refresh_state.py @@ -19,7 +19,7 @@ from pullbox.models.publisher import Publisher from pullbox.models.series import IssueCatalogState from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues -from pullbox.schemas.metadata_sources import SourcePolicyRead +from pullbox.schemas.metadata_sources import CatalogExcludedIssue, SourcePolicyRead from pullbox.services.metadata_baselines import MetadataBaselineConflictError from pullbox.services.metadata_catalog_checkpoints import ( CatalogCheckpoint, @@ -82,6 +82,7 @@ class SeriesRefreshState: issues: tuple[RefreshEntityState, ...] policies: tuple[SourcePolicyRead, ...] checkpoints: tuple[CatalogCheckpoint, ...] = () + catalog_exclusions: tuple[CatalogExcludedIssue, ...] = () def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot | None: @@ -263,5 +264,9 @@ async def read_series_refresh_state( for item in await read_source_policies(session) ) return SeriesRefreshState( - parent, tuple(issues), policies, await read_catalog_checkpoints(session, series_id) + parent, + tuple(issues), + policies, + await read_catalog_checkpoints(session, series_id), + tuple(CatalogExcludedIssue.model_validate(item) for item in series.catalog_exclusions), ) diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css index 79e331a3..3298e71b 100644 --- a/src/pullbox/ui/static/css/tailwind.css +++ b/src/pullbox/ui/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{overflow-wrap:anywhere;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{overflow-wrap:anywhere;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{overflow-wrap:anywhere;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{overflow-wrap:anywhere;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/src/pullbox/ui/templates/pages/add_series.html b/src/pullbox/ui/templates/pages/add_series.html index fa1fff2f..cce4c70a 100644 --- a/src/pullbox/ui/templates/pages/add_series.html +++ b/src/pullbox/ui/templates/pages/add_series.html @@ -137,7 +137,7 @@ >