Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Contracts for the local SHD.build game-data capture utility.
|
||||
|
||||
Importing this package does not inspect the environment or initialize runtime resources.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Module entry point for ``python -m game_data_capture``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
"""CLI contracts for the capture utility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from game_data_capture.config import AppConfig, ConfigError, ServerConfig, load_config
|
||||
from game_data_capture.doctor import doctor_as_dict, run_doctor
|
||||
from game_data_capture.paths import PathConfigurationError, RuntimePaths, resolve_runtime_paths
|
||||
from game_data_capture.runtime import CaptureRuntime, RuntimeFailure
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_CONFIG_ERROR = 2
|
||||
EXIT_UNIMPLEMENTED = 3
|
||||
EXIT_RUNTIME_ERROR = 4
|
||||
|
||||
|
||||
def _add_config_argument(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--config", type=Path, help="absolute path to a TOML configuration file")
|
||||
|
||||
|
||||
def _port_argument(raw_value: str) -> int:
|
||||
try:
|
||||
port = int(raw_value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") from error
|
||||
if not 1024 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535")
|
||||
return port
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the stable command surface without initializing runtime resources."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="shd-capture", description="Local SHD.build game-data capture"
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
doctor = subcommands.add_parser("doctor", help="report local prerequisite status")
|
||||
_add_config_argument(doctor)
|
||||
doctor.add_argument("--json", action="store_true", help="emit JSON diagnostics")
|
||||
|
||||
serve = subcommands.add_parser("serve", help="start the localhost review service")
|
||||
_add_config_argument(serve)
|
||||
serve.add_argument("--host", help="loopback IP literal override")
|
||||
serve.add_argument("--port", type=_port_argument, help="TCP port override")
|
||||
|
||||
watch = subcommands.add_parser("watch", help="watch the Wayland clipboard for images")
|
||||
_add_config_argument(watch)
|
||||
watch_modes = watch.add_mutually_exclusive_group()
|
||||
watch_modes.add_argument("--arm", action="store_true", help="arm live clipboard capture")
|
||||
watch_modes.add_argument(
|
||||
"--dry-run", action="store_true", help="validate the capture runtime plan"
|
||||
)
|
||||
|
||||
ingest_image = subcommands.add_parser("ingest-image", help="stage one image for capture")
|
||||
_add_config_argument(ingest_image)
|
||||
ingest_image.add_argument("image", type=Path)
|
||||
|
||||
import_video = subcommands.add_parser("import-video", help="stage a video for frame extraction")
|
||||
_add_config_argument(import_video)
|
||||
import_video.add_argument("video", type=Path)
|
||||
|
||||
export = subcommands.add_parser("export", help="write a versioned ISAC handoff")
|
||||
_add_config_argument(export)
|
||||
export.add_argument("destination", type=Path)
|
||||
|
||||
verify = subcommands.add_parser("verify", help="verify an evidence archive")
|
||||
_add_config_argument(verify)
|
||||
verify.add_argument("archive", type=Path)
|
||||
|
||||
evaluate = subcommands.add_parser("eval", help="evaluate local OCR and classification")
|
||||
_add_config_argument(evaluate)
|
||||
evaluate.add_argument("corpus", type=Path)
|
||||
|
||||
purge = subcommands.add_parser("purge", help="purge retained local data")
|
||||
_add_config_argument(purge)
|
||||
purge.add_argument("--confirm", action="store_true", help="confirm a future destructive purge")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _load_cli_config(config_path: Path | None) -> tuple[AppConfig, RuntimePaths]:
|
||||
paths = resolve_runtime_paths()
|
||||
return load_config(config_path=config_path, paths=paths), paths
|
||||
|
||||
|
||||
def _doctor(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
diagnostics = doctor_as_dict(run_doctor(config, paths))
|
||||
if arguments.json:
|
||||
print(json.dumps(diagnostics, separators=(",", ":"), sort_keys=True))
|
||||
else:
|
||||
for diagnostic in diagnostics:
|
||||
print(f"{diagnostic['name']}: {diagnostic['status']} ({diagnostic['detail']})")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _serve_contract(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
host = arguments.host if arguments.host is not None else config.server.host
|
||||
port = arguments.port if arguments.port is not None else config.server.port
|
||||
configured = replace(config, server=ServerConfig(host=host, port=port))
|
||||
CaptureRuntime(configured, paths).serve()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _unimplemented(command: str) -> int:
|
||||
print(
|
||||
f"{command}: subsystem is not implemented; requires gdc-runtime-integration",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_UNIMPLEMENTED
|
||||
|
||||
|
||||
def _watch(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
if not arguments.arm and not arguments.dry_run:
|
||||
return _unimplemented("watch")
|
||||
runtime = CaptureRuntime(config, paths)
|
||||
if arguments.dry_run:
|
||||
runtime.validate_plan()
|
||||
print("watch dry-run: capture runtime plan is valid")
|
||||
return EXIT_OK
|
||||
runtime.watch()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _ingest_image(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
CaptureRuntime(config, paths).ingest_image(arguments.image)
|
||||
print("image capture stored")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run one CLI command and return a stable process exit status."""
|
||||
|
||||
parser = build_parser()
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
if arguments.command == "doctor":
|
||||
return _doctor(arguments)
|
||||
if arguments.command == "serve":
|
||||
return _serve_contract(arguments)
|
||||
if arguments.command == "watch":
|
||||
return _watch(arguments)
|
||||
if arguments.command == "ingest-image":
|
||||
return _ingest_image(arguments)
|
||||
_load_cli_config(arguments.config)
|
||||
return _unimplemented(arguments.command)
|
||||
except (ConfigError, PathConfigurationError) as error:
|
||||
print(f"configuration error: {error}", file=sys.stderr)
|
||||
return EXIT_CONFIG_ERROR
|
||||
except RuntimeFailure:
|
||||
print("capture runtime failed", file=sys.stderr)
|
||||
return EXIT_RUNTIME_ERROR
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Typed, bounded configuration for the capture runtime.
|
||||
|
||||
Configuration parsing reads an explicit TOML file only when a caller invokes
|
||||
``load_config``. Importing this module has no filesystem or environment effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from game_data_capture.paths import RuntimePaths, resolve_runtime_paths
|
||||
|
||||
_ENVIRONMENT_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,63}$")
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""A bounded, safe-to-display configuration error."""
|
||||
|
||||
|
||||
def _error(field_name: str, constraint: str) -> ConfigError:
|
||||
return ConfigError(f"Invalid configuration: {field_name} {constraint}.")
|
||||
|
||||
|
||||
def _bounded_int(value: object, field_name: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
raise _error(field_name, f"must be an integer from {minimum} to {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: object, field_name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise _error(field_name, "must be a table")
|
||||
return value
|
||||
|
||||
|
||||
def _only_keys(section: Mapping[str, object], section_name: str, allowed: set[str]) -> None:
|
||||
if set(section) - allowed:
|
||||
raise _error(section_name, "contains an unsupported setting")
|
||||
|
||||
|
||||
def _string(value: object, field_name: str, maximum: int) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
raise _error(field_name, f"must be a non-empty string up to {maximum} characters")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServerConfig:
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
address = ipaddress.ip_address(self.host)
|
||||
except ValueError as error:
|
||||
raise _error("server.host", "must be a loopback IP literal") from error
|
||||
if not address.is_loopback:
|
||||
raise _error("server.host", "must be a loopback IP literal")
|
||||
_bounded_int(self.port, "server.port", 1024, 65535)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LifecycleConfig:
|
||||
"""One-instance settings consumed later by the runtime integration layer."""
|
||||
|
||||
instance_name: str = "shd-capture"
|
||||
lock_timeout_seconds: int = 5
|
||||
shutdown_timeout_seconds: int = 30
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", self.instance_name):
|
||||
raise _error(
|
||||
"lifecycle.instance_name", "must use lowercase letters, digits, and hyphens"
|
||||
)
|
||||
_bounded_int(self.lock_timeout_seconds, "lifecycle.lock_timeout_seconds", 0, 60)
|
||||
_bounded_int(self.shutdown_timeout_seconds, "lifecycle.shutdown_timeout_seconds", 1, 300)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureLimits:
|
||||
max_image_bytes: int = 32 * 1024 * 1024
|
||||
max_video_bytes: int = 4 * 1024 * 1024 * 1024
|
||||
max_queue_items: int = 64
|
||||
max_ocr_text_characters: int = 20_000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_bounded_int(self.max_image_bytes, "limits.max_image_bytes", 1, 64 * 1024 * 1024)
|
||||
_bounded_int(self.max_video_bytes, "limits.max_video_bytes", 1, 16 * 1024 * 1024 * 1024)
|
||||
_bounded_int(self.max_queue_items, "limits.max_queue_items", 1, 256)
|
||||
_bounded_int(self.max_ocr_text_characters, "limits.max_ocr_text_characters", 1, 100_000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenAIConfig:
|
||||
api_key_env: str = "OPENAI_API_KEY"
|
||||
model: str = "gpt-4.1-mini"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _ENVIRONMENT_NAME.fullmatch(self.api_key_env):
|
||||
raise _error("openai.api_key_env", "must be an environment variable name")
|
||||
_string(self.model, "openai.model", 128)
|
||||
|
||||
def is_configured(self, environment: Mapping[str, str] | None = None) -> bool:
|
||||
env = os.environ if environment is None else environment
|
||||
return bool(env.get(self.api_key_env))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppConfig:
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig)
|
||||
limits: CaptureLimits = field(default_factory=CaptureLimits)
|
||||
openai: OpenAIConfig = field(default_factory=OpenAIConfig)
|
||||
|
||||
|
||||
def _section(document: Mapping[str, object], name: str, allowed: set[str]) -> Mapping[str, object]:
|
||||
value = document.get(name, {})
|
||||
section = _mapping(value, name)
|
||||
_only_keys(section, name, allowed)
|
||||
return section
|
||||
|
||||
|
||||
def parse_config(document: Mapping[str, object]) -> AppConfig:
|
||||
"""Parse a TOML-shaped mapping with an allowlist of configuration keys."""
|
||||
|
||||
_only_keys(document, "root", {"server", "lifecycle", "limits", "openai"})
|
||||
server = _section(document, "server", {"host", "port"})
|
||||
lifecycle = _section(
|
||||
document, "lifecycle", {"instance_name", "lock_timeout_seconds", "shutdown_timeout_seconds"}
|
||||
)
|
||||
limits = _section(
|
||||
document,
|
||||
"limits",
|
||||
{"max_image_bytes", "max_video_bytes", "max_queue_items", "max_ocr_text_characters"},
|
||||
)
|
||||
openai = _section(document, "openai", {"api_key_env", "model"})
|
||||
|
||||
return AppConfig(
|
||||
server=ServerConfig(
|
||||
host=_string(server.get("host", "127.0.0.1"), "server.host", 64),
|
||||
port=_bounded_int(server.get("port", 8765), "server.port", 1024, 65535),
|
||||
),
|
||||
lifecycle=LifecycleConfig(
|
||||
instance_name=_string(
|
||||
lifecycle.get("instance_name", "shd-capture"), "lifecycle.instance_name", 32
|
||||
),
|
||||
lock_timeout_seconds=_bounded_int(
|
||||
lifecycle.get("lock_timeout_seconds", 5), "lifecycle.lock_timeout_seconds", 0, 60
|
||||
),
|
||||
shutdown_timeout_seconds=_bounded_int(
|
||||
lifecycle.get("shutdown_timeout_seconds", 30),
|
||||
"lifecycle.shutdown_timeout_seconds",
|
||||
1,
|
||||
300,
|
||||
),
|
||||
),
|
||||
limits=CaptureLimits(
|
||||
max_image_bytes=_bounded_int(
|
||||
limits.get("max_image_bytes", 32 * 1024 * 1024),
|
||||
"limits.max_image_bytes",
|
||||
1,
|
||||
64 * 1024 * 1024,
|
||||
),
|
||||
max_video_bytes=_bounded_int(
|
||||
limits.get("max_video_bytes", 4 * 1024 * 1024 * 1024),
|
||||
"limits.max_video_bytes",
|
||||
1,
|
||||
16 * 1024 * 1024 * 1024,
|
||||
),
|
||||
max_queue_items=_bounded_int(
|
||||
limits.get("max_queue_items", 64), "limits.max_queue_items", 1, 256
|
||||
),
|
||||
max_ocr_text_characters=_bounded_int(
|
||||
limits.get("max_ocr_text_characters", 20_000),
|
||||
"limits.max_ocr_text_characters",
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
),
|
||||
openai=OpenAIConfig(
|
||||
api_key_env=_string(
|
||||
openai.get("api_key_env", "OPENAI_API_KEY"), "openai.api_key_env", 64
|
||||
),
|
||||
model=_string(openai.get("model", "gpt-4.1-mini"), "openai.model", 128),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None, paths: RuntimePaths | None = None) -> AppConfig:
|
||||
"""Read optional TOML configuration from the XDG config location.
|
||||
|
||||
An absent file selects safe defaults. Parse failures intentionally omit source values and paths.
|
||||
"""
|
||||
|
||||
selected_path = (
|
||||
config_path if config_path is not None else (paths or resolve_runtime_paths()).config_file
|
||||
)
|
||||
if not selected_path.is_absolute() or len(str(selected_path)) > 240:
|
||||
raise _error("config path", "must be an absolute path within 240 characters")
|
||||
if not selected_path.exists():
|
||||
return AppConfig()
|
||||
try:
|
||||
with selected_path.open("rb") as handle:
|
||||
document: Any = tomllib.load(handle)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
raise _error("config file", "could not be read") from error
|
||||
if not isinstance(document, dict):
|
||||
raise _error("root", "must be a table")
|
||||
return parse_config(document)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Side-effect-limited local diagnostics for capture prerequisites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from os import environ
|
||||
|
||||
from game_data_capture.config import AppConfig
|
||||
from game_data_capture.paths import (
|
||||
PathConfigurationError,
|
||||
RuntimePaths,
|
||||
prepare_private_directories,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Diagnostic:
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
|
||||
def _executable_diagnostic(name: str) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
name=name,
|
||||
status="available" if shutil.which(name) else "unavailable",
|
||||
detail="executable found on PATH" if shutil.which(name) else "executable not found on PATH",
|
||||
)
|
||||
|
||||
|
||||
def _gpu_diagnostic(environment: Mapping[str, str]) -> Diagnostic:
|
||||
configured_provider = environment.get("GDC_OCR_PROVIDER")
|
||||
if configured_provider:
|
||||
return Diagnostic(
|
||||
"gpu_provider", "unknown", "provider configured; runtime probe is deferred"
|
||||
)
|
||||
if importlib.util.find_spec("torch") is not None:
|
||||
return Diagnostic(
|
||||
"gpu_provider", "unknown", "torch is installed; runtime probe is deferred"
|
||||
)
|
||||
return Diagnostic("gpu_provider", "unknown", "no provider selected; runtime probe is deferred")
|
||||
|
||||
|
||||
def _storage_diagnostic(paths: RuntimePaths) -> Diagnostic:
|
||||
try:
|
||||
prepare_private_directories(paths)
|
||||
except (OSError, PathConfigurationError):
|
||||
return Diagnostic(
|
||||
"storage_permissions", "unavailable", "private XDG directories are not usable"
|
||||
)
|
||||
return Diagnostic(
|
||||
"storage_permissions", "available", "private XDG directories use owner-only access"
|
||||
)
|
||||
|
||||
|
||||
def run_doctor(
|
||||
config: AppConfig, paths: RuntimePaths, environment: Mapping[str, str] | None = None
|
||||
) -> tuple[Diagnostic, ...]:
|
||||
"""Inspect local prerequisites without launching processes or making network calls."""
|
||||
|
||||
env = environ if environment is None else environment
|
||||
return (
|
||||
_executable_diagnostic("niri"),
|
||||
_executable_diagnostic("wl-paste"),
|
||||
_executable_diagnostic("ffmpeg"),
|
||||
_gpu_diagnostic(env),
|
||||
_storage_diagnostic(paths),
|
||||
Diagnostic(
|
||||
"openai",
|
||||
"configured" if config.openai.is_configured(env) else "missing",
|
||||
"environment variable is present"
|
||||
if config.openai.is_configured(env)
|
||||
else "environment variable is absent",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def doctor_as_dict(diagnostics: tuple[Diagnostic, ...]) -> list[dict[str, str]]:
|
||||
"""Return diagnostics in a serialization-ready form without paths or secret values."""
|
||||
|
||||
return [asdict(diagnostic) for diagnostic in diagnostics]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Side-effect-free domain contracts for the game data capture utility."""
|
||||
|
||||
from .state_machines import InvalidStateTransition, StateMachine, require_transition
|
||||
from .taxonomy import (
|
||||
ABSTENTION_REASONS,
|
||||
GEAR_SLOTS,
|
||||
RECORD_TYPES_BY_VIEW,
|
||||
SCREEN_VIEWS,
|
||||
AbstentionReason,
|
||||
Family,
|
||||
GearSlot,
|
||||
RecordType,
|
||||
)
|
||||
from .validation import (
|
||||
ContractValidationError,
|
||||
ensure_supported_version,
|
||||
validate_archive_document,
|
||||
validate_archive_references,
|
||||
validate_classification,
|
||||
validate_handoff_document,
|
||||
validate_handoff_references,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ABSTENTION_REASONS",
|
||||
"GEAR_SLOTS",
|
||||
"RECORD_TYPES_BY_VIEW",
|
||||
"SCREEN_VIEWS",
|
||||
"AbstentionReason",
|
||||
"ContractValidationError",
|
||||
"Family",
|
||||
"GearSlot",
|
||||
"InvalidStateTransition",
|
||||
"RecordType",
|
||||
"StateMachine",
|
||||
"ensure_supported_version",
|
||||
"require_transition",
|
||||
"validate_archive_document",
|
||||
"validate_archive_references",
|
||||
"validate_classification",
|
||||
"validate_handoff_document",
|
||||
"validate_handoff_references",
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Typed Python views of the portable contract structures."""
|
||||
|
||||
import typing
|
||||
|
||||
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
||||
type MissingReason = typing.Literal[
|
||||
"not_visible",
|
||||
"occluded",
|
||||
"unreadable",
|
||||
"not_present_on_screen",
|
||||
"unsupported",
|
||||
"ambiguous",
|
||||
"extraction_failed",
|
||||
"not_applicable",
|
||||
"redacted",
|
||||
]
|
||||
|
||||
|
||||
class SourceEngine(typing.TypedDict):
|
||||
kind: typing.Literal["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]
|
||||
engine_id: str
|
||||
engine_version: str
|
||||
|
||||
|
||||
class NormalizedRegion(typing.TypedDict):
|
||||
coordinate_space: typing.Literal["normalized"]
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class PixelRegion(typing.TypedDict):
|
||||
coordinate_space: typing.Literal["pixels"]
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
type BoundingRegion = NormalizedRegion | PixelRegion
|
||||
|
||||
|
||||
class ValueState(typing.TypedDict):
|
||||
raw_text: str | None
|
||||
normalized_value: JsonValue
|
||||
missing_reason: MissingReason | None
|
||||
|
||||
|
||||
class Correction(typing.TypedDict):
|
||||
correction_id: str
|
||||
corrected_at: str
|
||||
actor: typing.Literal["local_user", "system_reprocess"]
|
||||
reason: str
|
||||
before: ValueState
|
||||
after: ValueState
|
||||
|
||||
|
||||
class EvidenceValue(ValueState):
|
||||
evidence_id: str
|
||||
field_path: str
|
||||
confidence: float
|
||||
source_engine: SourceEngine
|
||||
image_artifact_id: str
|
||||
image_sha256: str
|
||||
region: BoundingRegion
|
||||
correction_history: list[Correction]
|
||||
|
||||
|
||||
class FieldClaim(typing.TypedDict):
|
||||
path: str
|
||||
value: JsonValue
|
||||
missing_reason: MissingReason | None
|
||||
evidence_refs: list[str]
|
||||
uncertainty_note: str | None
|
||||
|
||||
|
||||
class ScreenshotClassification(typing.TypedDict):
|
||||
family: typing.Literal[
|
||||
"weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"
|
||||
]
|
||||
view: str
|
||||
gear_slot: (
|
||||
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
|
||||
)
|
||||
record_types: list[str]
|
||||
confidence: float
|
||||
abstention_reason: str | None
|
||||
|
||||
|
||||
class ObservedContext(typing.TypedDict):
|
||||
level: FieldClaim
|
||||
quality: FieldClaim
|
||||
tier: FieldClaim
|
||||
upgrade_state: FieldClaim
|
||||
|
||||
|
||||
class ObservedItemInstance(typing.TypedDict):
|
||||
"""Values shown for one configured item; they are not reusable definitions."""
|
||||
|
||||
instance_id: str
|
||||
family: str
|
||||
source_screen_ids: list[str]
|
||||
observed_context: ObservedContext
|
||||
fields: list[FieldClaim]
|
||||
|
||||
|
||||
class CanonicalDefinitionCandidate(typing.TypedDict):
|
||||
"""Evidence-backed reusable claims, kept separate from the observed item."""
|
||||
|
||||
candidate_id: str
|
||||
record_type: str
|
||||
source_instance_ids: list[str]
|
||||
evidence_refs: list[str]
|
||||
fields: list[FieldClaim]
|
||||
completeness: typing.Literal["complete", "incomplete", "unsupported"]
|
||||
relationship_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
||||
mechanic_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Trust-boundary ports. Implementations belong to downstream tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceBudget:
|
||||
deadline_monotonic: float | None
|
||||
max_input_bytes: int
|
||||
max_output_bytes: int
|
||||
max_items: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
limits: tuple[tuple[str, object], ...] = (
|
||||
("max_input_bytes", self.max_input_bytes),
|
||||
("max_output_bytes", self.max_output_bytes),
|
||||
("max_items", self.max_items),
|
||||
)
|
||||
for name, value in limits:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
deadline: object = self.deadline_monotonic
|
||||
if deadline is not None:
|
||||
if isinstance(deadline, bool) or not isinstance(deadline, int | float):
|
||||
raise ValueError("deadline_monotonic must be a finite number or null")
|
||||
if not isfinite(deadline):
|
||||
raise ValueError("deadline_monotonic must be finite or null")
|
||||
|
||||
|
||||
class CancellationToken(Protocol):
|
||||
def is_cancelled(self) -> bool: ...
|
||||
|
||||
def raise_if_cancelled(self) -> None: ...
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
def now_utc(self) -> datetime: ...
|
||||
|
||||
def monotonic(self) -> float: ...
|
||||
|
||||
|
||||
class IdGenerator(Protocol):
|
||||
def new_id(self) -> str: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlobReceipt:
|
||||
sha256: str
|
||||
byte_size: int
|
||||
media_type: str
|
||||
|
||||
|
||||
class BlobStorage(Protocol):
|
||||
"""Stores immutable bytes by digest without exposing filesystem paths."""
|
||||
|
||||
def put(
|
||||
self,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
expected_sha256: str | None,
|
||||
media_type: str,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> BlobReceipt: ...
|
||||
|
||||
def read(
|
||||
self,
|
||||
sha256: str,
|
||||
*,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> Iterable[bytes]: ...
|
||||
|
||||
def exists(self, sha256: str) -> bool: ...
|
||||
|
||||
|
||||
class Repository[RecordT](Protocol):
|
||||
def get(self, record_id: str) -> RecordT | None: ...
|
||||
|
||||
def add(self, record: RecordT) -> None: ...
|
||||
|
||||
def replace(self, record: RecordT, *, expected_revision: int) -> None: ...
|
||||
|
||||
|
||||
type CaptureRepository = Repository[object]
|
||||
type JobRepository = Repository[object]
|
||||
type ExtractionRepository = Repository[object]
|
||||
type GroupingRepository = Repository[object]
|
||||
type ReviewRepository = Repository[object]
|
||||
type ExportRepository = Repository[object]
|
||||
type ErrorRepository = Repository[object]
|
||||
|
||||
|
||||
class UnitOfWork(Protocol):
|
||||
captures: CaptureRepository
|
||||
jobs: JobRepository
|
||||
extractions: ExtractionRepository
|
||||
groupings: GroupingRepository
|
||||
reviews: ReviewRepository
|
||||
exports: ExportRepository
|
||||
errors: ErrorRepository
|
||||
|
||||
def commit(self) -> None: ...
|
||||
|
||||
def rollback(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImageInput:
|
||||
artifact_id: str
|
||||
sha256: str
|
||||
media_type: str
|
||||
byte_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineResult:
|
||||
engine_id: str
|
||||
engine_version: str
|
||||
payload: Mapping[str, object]
|
||||
|
||||
|
||||
class OcrEngine(Protocol):
|
||||
def extract(
|
||||
self,
|
||||
image: ImageInput,
|
||||
*,
|
||||
regions: Sequence[Mapping[str, int | float | str]],
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> EngineResult: ...
|
||||
|
||||
|
||||
class VisionFallback(Protocol):
|
||||
"""Accepts bounded artifacts and never receives credentials in a request."""
|
||||
|
||||
def extract(
|
||||
self,
|
||||
images: Sequence[ImageInput],
|
||||
*,
|
||||
response_schema: Mapping[str, object],
|
||||
request_fingerprint: str,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> EngineResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubprocessRequest:
|
||||
tool: SubprocessTool
|
||||
arguments: tuple[str, ...]
|
||||
environment: Mapping[SubprocessEnvironmentKey, str]
|
||||
stdin: bytes | None
|
||||
budget: ResourceBudget
|
||||
|
||||
|
||||
class SubprocessTool(StrEnum):
|
||||
WL_PASTE = "wl-paste"
|
||||
FFMPEG = "ffmpeg"
|
||||
FFPROBE = "ffprobe"
|
||||
|
||||
|
||||
class SubprocessEnvironmentKey(StrEnum):
|
||||
WAYLAND_DISPLAY = "WAYLAND_DISPLAY"
|
||||
XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR"
|
||||
PATH = "PATH"
|
||||
HOME = "HOME"
|
||||
LANG = "LANG"
|
||||
LC_ALL = "LC_ALL"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubprocessResult:
|
||||
return_code: int
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
timed_out: bool
|
||||
cancelled: bool
|
||||
|
||||
|
||||
class SubprocessRunner(Protocol):
|
||||
"""Runs an argument array without a shell and with an explicit environment."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
request: SubprocessRequest,
|
||||
*,
|
||||
cancellation: CancellationToken,
|
||||
) -> SubprocessResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrameScore:
|
||||
timestamp_ms: int
|
||||
stability: float
|
||||
sharpness: float
|
||||
ui_coverage: float
|
||||
|
||||
|
||||
class FrameScorer(Protocol):
|
||||
def score(
|
||||
self,
|
||||
frames: Sequence[ImageInput],
|
||||
*,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> Sequence[FrameScore]: ...
|
||||
@@ -0,0 +1,211 @@
|
||||
"""State vocabularies and allowed transitions for recoverable workflows."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
||||
class CaptureState(StrEnum):
|
||||
RECEIVED = "received"
|
||||
SPOOLING = "spooling"
|
||||
STORED = "stored"
|
||||
DEDUPLICATED = "deduplicated"
|
||||
QUEUED = "queued"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobState(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
RETRY_WAIT = "retry_wait"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ExtractionState(StrEnum):
|
||||
PENDING = "pending"
|
||||
LOCAL_PROCESSING = "local_processing"
|
||||
FALLBACK_PENDING = "fallback_pending"
|
||||
FALLBACK_PROCESSING = "fallback_processing"
|
||||
CANDIDATE_READY = "candidate_ready"
|
||||
ABSTAINED = "abstained"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class GroupingState(StrEnum):
|
||||
UNGROUPED = "ungrouped"
|
||||
PROPOSED = "proposed"
|
||||
GROUPED = "grouped"
|
||||
SPLIT = "split"
|
||||
SUPERSEDED = "superseded"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ReviewState(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_REVIEW = "in_review"
|
||||
CHANGES_REQUESTED = "changes_requested"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ExportState(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
QUEUED = "queued"
|
||||
BUILDING = "building"
|
||||
READY = "ready"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class ErrorState(StrEnum):
|
||||
OPEN = "open"
|
||||
RETRYABLE = "retryable"
|
||||
RETRYING = "retrying"
|
||||
RECOVERED = "recovered"
|
||||
TERMINAL = "terminal"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateMachine:
|
||||
name: str
|
||||
transitions: Mapping[str, frozenset[str]]
|
||||
|
||||
def allows(self, current: str, target: str) -> bool:
|
||||
return target in self.transitions.get(current, frozenset())
|
||||
|
||||
|
||||
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
|
||||
return StateMachine(
|
||||
name=name,
|
||||
transitions=MappingProxyType(
|
||||
{state: frozenset(targets) for state, targets in transitions.items()}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
|
||||
{
|
||||
"capture": _machine(
|
||||
"capture",
|
||||
{
|
||||
"received": {"spooling", "deduplicated", "failed", "cancelled"},
|
||||
"spooling": {"stored", "failed", "cancelled"},
|
||||
"stored": {"queued", "failed"},
|
||||
"deduplicated": set(),
|
||||
"queued": set(),
|
||||
"failed": {"spooling", "cancelled"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"job": _machine(
|
||||
"job",
|
||||
{
|
||||
"queued": {"running", "cancelled"},
|
||||
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
|
||||
"retry_wait": {"queued", "failed", "cancelled"},
|
||||
"succeeded": set(),
|
||||
"failed": {"queued"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"extraction": _machine(
|
||||
"extraction",
|
||||
{
|
||||
"pending": {"local_processing", "cancelled"},
|
||||
"local_processing": {
|
||||
"candidate_ready",
|
||||
"fallback_pending",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled",
|
||||
},
|
||||
"fallback_pending": {"fallback_processing", "cancelled"},
|
||||
"fallback_processing": {
|
||||
"candidate_ready",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled",
|
||||
},
|
||||
"candidate_ready": set(),
|
||||
"abstained": set(),
|
||||
"failed": {"pending"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"grouping": _machine(
|
||||
"grouping",
|
||||
{
|
||||
"ungrouped": {"proposed", "cancelled"},
|
||||
"proposed": {"grouped", "rejected", "cancelled"},
|
||||
"grouped": {"split", "superseded"},
|
||||
"split": {"superseded"},
|
||||
"superseded": set(),
|
||||
"rejected": {"ungrouped"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"review": _machine(
|
||||
"review",
|
||||
{
|
||||
"pending": {"in_review", "cancelled"},
|
||||
"in_review": {
|
||||
"changes_requested",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
},
|
||||
"changes_requested": {"in_review", "rejected", "cancelled"},
|
||||
"approved": {"in_review"},
|
||||
"rejected": {"in_review"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"export": _machine(
|
||||
"export",
|
||||
{
|
||||
"not_requested": {"queued"},
|
||||
"queued": {"building", "cancelled"},
|
||||
"building": {"ready", "failed", "cancelled"},
|
||||
"ready": {"expired"},
|
||||
"failed": {"queued", "cancelled"},
|
||||
"cancelled": set(),
|
||||
"expired": {"queued"},
|
||||
},
|
||||
),
|
||||
"error": _machine(
|
||||
"error",
|
||||
{
|
||||
"open": {"retryable", "terminal", "dismissed"},
|
||||
"retryable": {"retrying", "terminal", "dismissed"},
|
||||
"retrying": {"recovered", "retryable", "terminal"},
|
||||
"recovered": set(),
|
||||
"terminal": {"dismissed"},
|
||||
"dismissed": set(),
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class InvalidStateTransition(ValueError):
|
||||
"""Raised when a caller attempts a transition absent from the contract."""
|
||||
|
||||
|
||||
def require_transition(machine_name: str, current: str, target: str) -> None:
|
||||
machine = STATE_MACHINES.get(machine_name)
|
||||
if machine is None:
|
||||
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
|
||||
if current not in machine.transitions:
|
||||
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
|
||||
if not machine.allows(current, target):
|
||||
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Frozen screenshot taxonomy and canonical record vocabulary for contract v1."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Family(StrEnum):
|
||||
WEAPON = "weapon"
|
||||
GEAR = "gear"
|
||||
SKILL_CHIP = "skill_chip"
|
||||
OS_PROTOCOL = "os_protocol"
|
||||
SPECIALIZATION = "specialization"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class GearSlot(StrEnum):
|
||||
MASK = "Mask"
|
||||
GLOVES = "Gloves"
|
||||
BACKPACK = "Backpack"
|
||||
BODY_ARMOR = "Body Armor"
|
||||
HOLSTER = "Holster"
|
||||
KNEE_PADS = "Knee Pads"
|
||||
|
||||
|
||||
class AbstentionReason(StrEnum):
|
||||
LOW_CONFIDENCE = "low_confidence"
|
||||
AMBIGUOUS_FAMILY = "ambiguous_family"
|
||||
AMBIGUOUS_VIEW = "ambiguous_view"
|
||||
UNSUPPORTED_FAMILY = "unsupported_family"
|
||||
UNSUPPORTED_VIEW = "unsupported_view"
|
||||
INSUFFICIENT_VISUAL_EVIDENCE = "insufficient_visual_evidence"
|
||||
OCCLUDED = "occluded"
|
||||
CORRUPT_IMAGE = "corrupt_image"
|
||||
RESOURCE_LIMIT = "resource_limit"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RecordType(StrEnum):
|
||||
WEAPON = "weapon"
|
||||
WEAPON_TALENT = "weapon_talent"
|
||||
WEAPON_AUGMENTATION_PROFILE = "weapon_augmentation_profile"
|
||||
GEAR = "gear"
|
||||
BRAND_SET = "brand_set"
|
||||
GEAR_TALENT = "gear_talent"
|
||||
GEAR_AUGMENTATION_PROFILE = "gear_augmentation_profile"
|
||||
SKILL_CHIP_SET = "skill_chip_set"
|
||||
SKILL_CHIP_SET_BONUS = "skill_chip_set_bonus"
|
||||
SKILL_CHIP_ROLL_POOL = "skill_chip_roll_pool"
|
||||
SKILL_CHIP_TALENT = "skill_chip_talent"
|
||||
OS_PROTOCOL = "os_protocol"
|
||||
OS_PROTOCOL_SECONDARY_PROFILE = "os_protocol_secondary_profile"
|
||||
OS_PROTOCOL_TALENT = "os_protocol_talent"
|
||||
SPECIALIZATION = "specialization"
|
||||
SPECIALIZATION_FOCUS = "specialization_focus"
|
||||
SPECIALIZATION_ABILITY = "specialization_ability"
|
||||
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
|
||||
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
|
||||
|
||||
|
||||
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
|
||||
{
|
||||
Family.WEAPON: (
|
||||
"detail",
|
||||
"blueprint",
|
||||
"crafting",
|
||||
"stat_range",
|
||||
"talent_grid",
|
||||
"augmentation",
|
||||
),
|
||||
Family.GEAR: ("detail", "blueprint", "talent", "augmentation"),
|
||||
Family.SKILL_CHIP: ("detail", "set_bonus", "talent"),
|
||||
Family.OS_PROTOCOL: ("detail", "secondary_profile", "talent"),
|
||||
Family.SPECIALIZATION: (
|
||||
"overview",
|
||||
"details",
|
||||
"ability",
|
||||
"focus_details",
|
||||
"focus_tree",
|
||||
"rank_details",
|
||||
),
|
||||
Family.UNKNOWN: ("unknown",),
|
||||
}
|
||||
)
|
||||
|
||||
GEAR_SLOTS: Final[tuple[str, ...]] = tuple(slot.value for slot in GearSlot)
|
||||
ABSTENTION_REASONS: Final[tuple[str, ...]] = tuple(reason.value for reason in AbstentionReason)
|
||||
|
||||
RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] = MappingProxyType(
|
||||
{
|
||||
(Family.WEAPON, "detail"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
||||
(Family.WEAPON, "blueprint"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
||||
(Family.WEAPON, "crafting"): (RecordType.WEAPON,),
|
||||
(Family.WEAPON, "stat_range"): (RecordType.WEAPON,),
|
||||
(Family.WEAPON, "talent_grid"): (RecordType.WEAPON_TALENT,),
|
||||
(Family.WEAPON, "augmentation"): (RecordType.WEAPON_AUGMENTATION_PROFILE,),
|
||||
(Family.GEAR, "detail"): (RecordType.GEAR, RecordType.BRAND_SET),
|
||||
(Family.GEAR, "blueprint"): (RecordType.GEAR, RecordType.BRAND_SET),
|
||||
(Family.GEAR, "talent"): (RecordType.GEAR_TALENT,),
|
||||
(Family.GEAR, "augmentation"): (RecordType.GEAR_AUGMENTATION_PROFILE,),
|
||||
(Family.SKILL_CHIP, "detail"): (
|
||||
RecordType.SKILL_CHIP_SET,
|
||||
RecordType.SKILL_CHIP_ROLL_POOL,
|
||||
),
|
||||
(Family.SKILL_CHIP, "set_bonus"): (RecordType.SKILL_CHIP_SET_BONUS,),
|
||||
(Family.SKILL_CHIP, "talent"): (RecordType.SKILL_CHIP_TALENT,),
|
||||
(Family.OS_PROTOCOL, "detail"): (RecordType.OS_PROTOCOL,),
|
||||
(Family.OS_PROTOCOL, "secondary_profile"): (RecordType.OS_PROTOCOL_SECONDARY_PROFILE,),
|
||||
(Family.OS_PROTOCOL, "talent"): (RecordType.OS_PROTOCOL_TALENT,),
|
||||
(Family.SPECIALIZATION, "overview"): (RecordType.SPECIALIZATION,),
|
||||
(Family.SPECIALIZATION, "details"): (RecordType.SPECIALIZATION,),
|
||||
(Family.SPECIALIZATION, "ability"): (RecordType.SPECIALIZATION_ABILITY,),
|
||||
(Family.SPECIALIZATION, "focus_details"): (RecordType.SPECIALIZATION_FOCUS,),
|
||||
(Family.SPECIALIZATION, "focus_tree"): (
|
||||
RecordType.SPECIALIZATION_FOCUS,
|
||||
RecordType.SPECIALIZATION_TREE_NODE,
|
||||
),
|
||||
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
|
||||
(Family.UNKNOWN, "unknown"): (),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Semantic checks that portable JSON Schema cannot express."""
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from . import taxonomy
|
||||
|
||||
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
|
||||
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ContractValidationError(ValueError):
|
||||
"""Raised when a document violates a version or referential contract."""
|
||||
|
||||
|
||||
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
|
||||
version = document.get("schema_version")
|
||||
if type(version) is not int or version != expected:
|
||||
raise ContractValidationError(
|
||||
f"Unsupported schema_version {version!r}; expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
|
||||
if not isinstance(value, list):
|
||||
raise ContractValidationError(f"{field} must be an array")
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ContractValidationError(f"{field}[{index}] must be an object")
|
||||
yield item
|
||||
|
||||
|
||||
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for item in items:
|
||||
value = item.get(field)
|
||||
if not isinstance(value, str):
|
||||
raise ContractValidationError(f"{field} must be a string")
|
||||
if value in result:
|
||||
raise ContractValidationError(f"Duplicate {field}: {value}")
|
||||
result.add(value)
|
||||
return result
|
||||
|
||||
|
||||
def _references(item: Mapping[str, object], field: str) -> set[str]:
|
||||
raw = item.get(field, [])
|
||||
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
|
||||
raise ContractValidationError(f"{field} must contain string IDs")
|
||||
return set(raw)
|
||||
|
||||
|
||||
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
|
||||
dangling = values - allowed
|
||||
if dangling:
|
||||
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
|
||||
|
||||
|
||||
def validate_classification(classification: Mapping[str, object]) -> None:
|
||||
family_raw = classification.get("family")
|
||||
if not isinstance(family_raw, str):
|
||||
raise ContractValidationError("Unknown classification family")
|
||||
try:
|
||||
family = taxonomy.Family(family_raw)
|
||||
except ValueError as error:
|
||||
raise ContractValidationError("Unknown classification family") from error
|
||||
view = classification.get("view")
|
||||
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
|
||||
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
|
||||
confidence = classification.get("confidence")
|
||||
if (
|
||||
isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or not 0 <= confidence <= 1
|
||||
):
|
||||
raise ContractValidationError("Classification confidence must be between 0 and 1")
|
||||
gear_slot = classification.get("gear_slot")
|
||||
if family is taxonomy.Family.GEAR:
|
||||
if gear_slot not in taxonomy.GEAR_SLOTS:
|
||||
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
|
||||
elif gear_slot is not None:
|
||||
raise ContractValidationError("Only Gear classifications may set gear_slot")
|
||||
record_types = classification.get("record_types")
|
||||
if not isinstance(record_types, list) or any(
|
||||
not isinstance(record_type, str) for record_type in record_types
|
||||
):
|
||||
raise ContractValidationError("record_types must contain strings")
|
||||
if len(record_types) != len(set(record_types)):
|
||||
raise ContractValidationError("record_types must be unique")
|
||||
allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]}
|
||||
if family is taxonomy.Family.UNKNOWN:
|
||||
if record_types:
|
||||
raise ContractValidationError("Unknown screens cannot claim canonical record types")
|
||||
reason = classification.get("abstention_reason")
|
||||
if reason not in taxonomy.ABSTENTION_REASONS:
|
||||
raise ContractValidationError("Unknown screens require an abstention reason")
|
||||
else:
|
||||
if not record_types or not set(record_types).issubset(allowed):
|
||||
raise ContractValidationError(
|
||||
f"record_types are incompatible with {family.value}/{view}"
|
||||
)
|
||||
if classification.get("abstention_reason") is not None:
|
||||
raise ContractValidationError("Known screens cannot set abstention_reason")
|
||||
|
||||
|
||||
def _validate_evidence(value: Mapping[str, object]) -> None:
|
||||
_validate_value_state(value, "evidence")
|
||||
confidence = value.get("confidence")
|
||||
if (
|
||||
isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or not 0 <= confidence <= 1
|
||||
):
|
||||
raise ContractValidationError("Evidence confidence must be between 0 and 1")
|
||||
region = value.get("region")
|
||||
if not isinstance(region, Mapping):
|
||||
raise ContractValidationError("Evidence region must be an object")
|
||||
coordinate_space = region.get("coordinate_space")
|
||||
numeric_coordinates: list[int | float] = []
|
||||
for name in ("x", "y", "width", "height"):
|
||||
number = region.get(name)
|
||||
if isinstance(number, bool) or not isinstance(number, int | float):
|
||||
raise ContractValidationError("Evidence bounds must be numeric")
|
||||
numeric_coordinates.append(number)
|
||||
x, y, width, height = numeric_coordinates
|
||||
if coordinate_space == "normalized":
|
||||
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
|
||||
raise ContractValidationError("Normalized evidence bounds must fit within the image")
|
||||
elif coordinate_space == "pixels":
|
||||
if any(not isinstance(number, int) for number in numeric_coordinates):
|
||||
raise ContractValidationError("Pixel evidence bounds must be integers")
|
||||
if x < 0 or y < 0 or width <= 0 or height <= 0:
|
||||
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
|
||||
else:
|
||||
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
|
||||
|
||||
|
||||
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
|
||||
raw_text = value.get("raw_text")
|
||||
normalized = value.get("normalized_value")
|
||||
missing_reason = value.get("missing_reason")
|
||||
if raw_text is not None and not isinstance(raw_text, str):
|
||||
raise ContractValidationError(f"{context} raw_text must be a string or null")
|
||||
if normalized is None and not isinstance(missing_reason, str):
|
||||
raise ContractValidationError(f"Null {context} requires missing_reason")
|
||||
if normalized is not None and missing_reason is not None:
|
||||
raise ContractValidationError(f"Present {context} cannot set missing_reason")
|
||||
|
||||
|
||||
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
|
||||
_validate_value_state(value, context)
|
||||
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
|
||||
|
||||
|
||||
def _parse_correction_time(value: object) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise ContractValidationError("Correction corrected_at must be a timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ContractValidationError("Correction corrected_at must include a UTC offset")
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_correction_history(
|
||||
evidence: Mapping[str, object], seen_correction_ids: set[str]
|
||||
) -> None:
|
||||
history = evidence.get("correction_history")
|
||||
if not isinstance(history, list):
|
||||
raise ContractValidationError("correction_history must be an array")
|
||||
previous_after: tuple[object, object, object] | None = None
|
||||
previous_time: datetime | None = None
|
||||
for index, correction in enumerate(history):
|
||||
if not isinstance(correction, Mapping):
|
||||
raise ContractValidationError(f"correction_history[{index}] must be an object")
|
||||
correction_id = correction.get("correction_id")
|
||||
if not isinstance(correction_id, str):
|
||||
raise ContractValidationError("correction_id must be a string")
|
||||
if correction_id in seen_correction_ids:
|
||||
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
|
||||
seen_correction_ids.add(correction_id)
|
||||
corrected_at = _parse_correction_time(correction.get("corrected_at"))
|
||||
if previous_time is not None and corrected_at <= previous_time:
|
||||
raise ContractValidationError("Correction history timestamps must be strictly ordered")
|
||||
before = correction.get("before")
|
||||
after = correction.get("after")
|
||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
||||
raise ContractValidationError("Correction before and after must be value states")
|
||||
before_state = _value_state(before, "correction before")
|
||||
after_state = _value_state(after, "correction after")
|
||||
if previous_after is not None and before_state != previous_after:
|
||||
raise ContractValidationError("Correction history contains a disconnected value state")
|
||||
previous_after = after_state
|
||||
previous_time = corrected_at
|
||||
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
|
||||
raise ContractValidationError("Correction history final state does not match evidence")
|
||||
|
||||
|
||||
def validate_archive_document(document: Mapping[str, object]) -> None:
|
||||
validate_archive_references(document)
|
||||
for screen in _objects(document.get("screens"), "screens"):
|
||||
classification = screen.get("classification")
|
||||
if not isinstance(classification, Mapping):
|
||||
raise ContractValidationError("screen.classification must be an object")
|
||||
validate_classification(classification)
|
||||
seen_correction_ids: set[str] = set()
|
||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
||||
_validate_evidence(value)
|
||||
_validate_correction_history(value, seen_correction_ids)
|
||||
|
||||
|
||||
def validate_handoff_document(document: Mapping[str, object]) -> None:
|
||||
validate_handoff_references(document)
|
||||
expected_boundary: dict[str, object] = {
|
||||
"intended_destination": "authenticated_isac_intake",
|
||||
"mutation_authority": "none",
|
||||
"requires_authenticated_action": True,
|
||||
"preserves_moderation": True,
|
||||
"creates_audit_events": False,
|
||||
}
|
||||
if document.get("trust_boundary") != expected_boundary:
|
||||
raise ContractValidationError("The handoff trust boundary is invalid")
|
||||
seen_correction_ids: set[str] = set()
|
||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
||||
_validate_evidence(value)
|
||||
_validate_correction_history(value, seen_correction_ids)
|
||||
|
||||
|
||||
def validate_archive_references(document: Mapping[str, object]) -> None:
|
||||
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
|
||||
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
|
||||
captures = list(_objects(document.get("captures"), "captures"))
|
||||
screens = list(_objects(document.get("screens"), "screens"))
|
||||
jobs = list(_objects(document.get("jobs"), "jobs"))
|
||||
extractions = list(_objects(document.get("extractions"), "extractions"))
|
||||
groups = list(_objects(document.get("groups"), "groups"))
|
||||
reviews = list(_objects(document.get("reviews"), "reviews"))
|
||||
exports = list(_objects(document.get("exports"), "exports"))
|
||||
errors = list(_objects(document.get("errors"), "errors"))
|
||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
||||
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
|
||||
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
|
||||
artifact_ids = _ids(artifacts, "artifact_id")
|
||||
_ids(captures, "capture_id")
|
||||
screen_ids = _ids(screens, "screen_id")
|
||||
_ids(jobs, "job_id")
|
||||
_ids(extractions, "extraction_id")
|
||||
group_ids = _ids(groups, "group_id")
|
||||
_ids(reviews, "review_id")
|
||||
_ids(exports, "export_id")
|
||||
error_ids = _ids(errors, "error_id")
|
||||
evidence_ids = _ids(evidence, "evidence_id")
|
||||
instance_ids = _ids(instances, "instance_id")
|
||||
candidate_ids = _ids(candidates, "candidate_id")
|
||||
artifact_hashes = {
|
||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
||||
}
|
||||
|
||||
for capture in captures:
|
||||
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
|
||||
for artifact in artifacts:
|
||||
parent_id = artifact.get("parent_artifact_id")
|
||||
if parent_id is not None:
|
||||
if str(parent_id) == str(artifact.get("artifact_id")):
|
||||
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
|
||||
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
|
||||
for screen in screens:
|
||||
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
|
||||
for value in evidence:
|
||||
artifact_id = str(value.get("image_artifact_id"))
|
||||
_require_subset(
|
||||
{artifact_id},
|
||||
artifact_ids,
|
||||
"evidence.image_artifact_id",
|
||||
)
|
||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
||||
for job in jobs:
|
||||
last_error_id = job.get("last_error_id")
|
||||
if last_error_id is not None:
|
||||
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
|
||||
for extraction in extractions:
|
||||
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
|
||||
_require_subset(
|
||||
_references(extraction, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"extraction.evidence_refs",
|
||||
)
|
||||
for group in groups:
|
||||
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
|
||||
supersedes_group_ids = _references(group, "supersedes_group_ids")
|
||||
if str(group.get("group_id")) in supersedes_group_ids:
|
||||
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
|
||||
_require_subset(
|
||||
supersedes_group_ids,
|
||||
group_ids,
|
||||
"group.supersedes_group_ids",
|
||||
)
|
||||
for instance in instances:
|
||||
_require_subset(
|
||||
_references(instance, "source_screen_ids"),
|
||||
screen_ids,
|
||||
"observed_instance.source_screen_ids",
|
||||
)
|
||||
for field in _objects(instance.get("fields"), "observed_instance.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"observed_instance.fields.evidence_refs",
|
||||
)
|
||||
context = instance.get("observed_context")
|
||||
if not isinstance(context, Mapping):
|
||||
raise ContractValidationError("observed_instance.observed_context must be an object")
|
||||
for context_name in ("level", "quality", "tier", "upgrade_state"):
|
||||
context_field = context.get(context_name)
|
||||
if not isinstance(context_field, Mapping):
|
||||
raise ContractValidationError(f"observed_context.{context_name} must be an object")
|
||||
_require_subset(
|
||||
_references(context_field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
f"observed_context.{context_name}.evidence_refs",
|
||||
)
|
||||
for candidate in candidates:
|
||||
_require_subset(
|
||||
_references(candidate, "source_instance_ids"),
|
||||
instance_ids,
|
||||
"canonical_candidate.source_instance_ids",
|
||||
)
|
||||
_require_subset(
|
||||
_references(candidate, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"canonical_candidate.evidence_refs",
|
||||
)
|
||||
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"canonical_candidate.fields.evidence_refs",
|
||||
)
|
||||
for review in reviews:
|
||||
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
|
||||
for export in exports:
|
||||
export_artifact_id = export.get("artifact_id")
|
||||
if export_artifact_id is not None:
|
||||
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
|
||||
|
||||
|
||||
def validate_handoff_references(document: Mapping[str, object]) -> None:
|
||||
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
|
||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
||||
records = list(_objects(document.get("records"), "records"))
|
||||
evidence_ids = _ids(evidence, "evidence_id")
|
||||
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
|
||||
artifact_ids = _ids(artifacts, "artifact_id")
|
||||
artifact_hashes = {
|
||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
||||
}
|
||||
_ids(records, "handoff_record_id")
|
||||
for value in evidence:
|
||||
artifact_id = str(value.get("image_artifact_id"))
|
||||
_require_subset(
|
||||
{artifact_id},
|
||||
artifact_ids,
|
||||
"evidence.image_artifact_id",
|
||||
)
|
||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
||||
for record in records:
|
||||
for field in _objects(record.get("fields"), "record.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"record.fields.evidence_refs",
|
||||
)
|
||||
for section_name in ("relationships", "mechanics"):
|
||||
section = record.get(section_name)
|
||||
if not isinstance(section, Mapping):
|
||||
raise ContractValidationError(f"record.{section_name} must be an object")
|
||||
_require_subset(
|
||||
_references(section, "evidence_refs"),
|
||||
evidence_ids,
|
||||
f"record.{section_name}.evidence_refs",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Wayland clipboard intake and private spool boundaries.
|
||||
|
||||
Submodules stay unimported so the stdin helper can start independently.
|
||||
"""
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Bounded, shell-free supervision for Wayland PNG clipboard intake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, cast
|
||||
|
||||
from game_data_capture.ingest.spool_helper import (
|
||||
PNG_SIGNATURE,
|
||||
acknowledge_receipt,
|
||||
durable_receipts,
|
||||
reconcile_spool,
|
||||
)
|
||||
|
||||
_SAFE_ENV: Final = frozenset(
|
||||
{"PATH", "HOME", "LANG", "LC_ALL", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"}
|
||||
)
|
||||
_DENIED_ENV = re.compile(
|
||||
r"(?:openai|authorization|token|secret|python(?:home|path|startup|userbase))", re.I
|
||||
)
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_MAX_METADATA = 16 * 1024
|
||||
_MAX_RECEIPT = 1024
|
||||
|
||||
|
||||
def _integer(value: object, minimum: int, maximum: int) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
|
||||
|
||||
|
||||
def _finite(value: object, minimum: float, maximum: float) -> bool:
|
||||
return (
|
||||
isinstance(value, int | float)
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
and minimum < value <= maximum
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WatchConfig:
|
||||
spool: Path
|
||||
max_image_bytes: int
|
||||
max_queue_items: int
|
||||
policy_version: str
|
||||
max_restarts: int = 3
|
||||
restart_backoff_seconds: float = 0.25
|
||||
shutdown_timeout_seconds: float = 2.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.spool.is_absolute() or len(str(self.spool)) > 240:
|
||||
raise ValueError("spool must be a bounded absolute path")
|
||||
if not _integer(self.max_image_bytes, 8, 64 * 1024 * 1024):
|
||||
raise ValueError("invalid image limit")
|
||||
if not _integer(self.max_queue_items, 1, 256) or not _integer(self.max_restarts, 0, 10):
|
||||
raise ValueError("invalid queue or restart limit")
|
||||
if not _finite(self.restart_backoff_seconds, 0, 60) or not _finite(
|
||||
self.shutdown_timeout_seconds, 0, 30
|
||||
):
|
||||
raise ValueError("invalid timeout")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", self.policy_version):
|
||||
raise ValueError("invalid policy version")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FocusedWindowMetadata:
|
||||
app_id: str | None
|
||||
title: str | None
|
||||
window_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntakeReceipt:
|
||||
occurrence_id: str
|
||||
receipt_token: str
|
||||
sha256: str
|
||||
byte_size: int
|
||||
media_type: str
|
||||
spool_path: Path
|
||||
policy_version: str
|
||||
processing_version: str
|
||||
focused_window: FocusedWindowMetadata
|
||||
|
||||
|
||||
class VerifiedSpoolReader:
|
||||
"""Descriptor-verified stream for handing one receipt to GDC-003 safely."""
|
||||
|
||||
def __init__(self, receipt: IntakeReceipt) -> None:
|
||||
self.receipt = receipt
|
||||
self._fd: int | None = None
|
||||
|
||||
def __enter__(self) -> VerifiedSpoolReader:
|
||||
self._fd = _verify_spool_file(self.receipt)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
if self._fd is None:
|
||||
raise RuntimeError("verified reader is not open")
|
||||
while chunk := os.read(self._fd, 64 * 1024):
|
||||
yield chunk
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fd is not None:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
|
||||
|
||||
def restricted_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
source = os.environ if environment is None else environment
|
||||
return {
|
||||
key: value[:4096]
|
||||
for key, value in source.items()
|
||||
if key in _SAFE_ENV
|
||||
and not _DENIED_ENV.search(key)
|
||||
and isinstance(value, str)
|
||||
and "\x00" not in value
|
||||
}
|
||||
|
||||
|
||||
def _bounded_text(value: object) -> str | None:
|
||||
return " ".join(value.split())[:256] or None if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _bounded_command_output(
|
||||
arguments: list[str], environment: Mapping[str, str], timeout: float
|
||||
) -> bytes | None:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
shell=False,
|
||||
env=environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
output = bytearray()
|
||||
done = threading.Event()
|
||||
|
||||
def collect() -> None:
|
||||
if process.stdout is not None:
|
||||
while len(output) <= _MAX_METADATA:
|
||||
chunk = process.stdout.read(min(4096, _MAX_METADATA + 1 - len(output)))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
done.set()
|
||||
|
||||
reader = threading.Thread(target=collect, daemon=True)
|
||||
reader.start()
|
||||
if not done.wait(timeout):
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=timeout)
|
||||
if process.poll() is None:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
reader.join(timeout)
|
||||
return (
|
||||
bytes(output)
|
||||
if done.is_set() and process.returncode == 0 and len(output) <= _MAX_METADATA
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def focused_window(
|
||||
*, environment: Mapping[str, str] | None = None, timeout_seconds: float = 0.5
|
||||
) -> FocusedWindowMetadata:
|
||||
if not _finite(timeout_seconds, 0, 30):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
raw = _bounded_command_output(
|
||||
["niri", "msg", "-j", "focused-window"],
|
||||
restricted_environment(environment),
|
||||
float(timeout_seconds),
|
||||
)
|
||||
if raw is None:
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
if not isinstance(payload, dict):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
identifier = payload.get("id")
|
||||
return FocusedWindowMetadata(
|
||||
_bounded_text(payload.get("app_id")),
|
||||
_bounded_text(payload.get("title")),
|
||||
identifier if _integer(identifier, 0, 2**63 - 1) else None,
|
||||
)
|
||||
|
||||
|
||||
def _verify_spool_file(receipt: IntakeReceipt) -> int:
|
||||
spool = receipt.spool_path.parent
|
||||
if spool.is_symlink() or not spool.is_dir() or (spool.stat().st_mode & 0o777) != 0o700:
|
||||
raise ValueError("unsafe spool")
|
||||
fd = os.open(receipt.spool_path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or (info.st_mode & 0o777) != 0o600
|
||||
or info.st_size != receipt.byte_size
|
||||
):
|
||||
raise ValueError("invalid spool file")
|
||||
first = os.read(fd, len(PNG_SIGNATURE))
|
||||
if first != PNG_SIGNATURE:
|
||||
raise ValueError("invalid spool png")
|
||||
hasher = hashlib.sha256(first)
|
||||
while chunk := os.read(fd, 64 * 1024):
|
||||
hasher.update(chunk)
|
||||
if hasher.hexdigest() != receipt.sha256:
|
||||
raise ValueError("spool digest mismatch")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
return fd
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
class ClipboardIntake:
|
||||
def __init__(
|
||||
self, config: WatchConfig, *, environment: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
self.config, self.environment = config, restricted_environment(environment)
|
||||
self.armed = self.paused = False
|
||||
self._watcher: subprocess.Popen[bytes] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._queue: deque[IntakeReceipt] = deque()
|
||||
self._helper_payloads: deque[dict[str, object]] = deque()
|
||||
self._seen_tokens: set[str] = set()
|
||||
self._seen_order: deque[str] = deque()
|
||||
self._recovered_tokens: set[str] = set()
|
||||
self._restart_count = 0
|
||||
self._restart_after = 0.0
|
||||
self._restarts_exhausted = False
|
||||
|
||||
@property
|
||||
def queue_depth(self) -> int:
|
||||
return len(self._queue)
|
||||
|
||||
@property
|
||||
def helper_queue_depth(self) -> int:
|
||||
return len(self._helper_payloads)
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed, self.paused = True, False
|
||||
self._start_if_due(time.monotonic())
|
||||
|
||||
def pause(self) -> None:
|
||||
self.paused = True
|
||||
self._stop_watcher()
|
||||
|
||||
def resume(self) -> None:
|
||||
if self.armed:
|
||||
self.paused = False
|
||||
self._start_if_due(time.monotonic())
|
||||
|
||||
def disarm(self) -> None:
|
||||
self.armed = self.paused = False
|
||||
self._stop_watcher()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.disarm()
|
||||
self._queue.clear()
|
||||
self._helper_payloads.clear()
|
||||
self._seen_tokens.clear()
|
||||
self._seen_order.clear()
|
||||
self._recovered_tokens.clear()
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
if self._watcher is not None and self._watcher.poll() is not None:
|
||||
self._stop_watcher()
|
||||
self._crashed(current)
|
||||
self._start_if_due(current)
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return self._queue.popleft() if self._queue else None
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return self._helper_payloads.popleft() if self._helper_payloads else None
|
||||
|
||||
def unreferenced_spool_candidates(self) -> list[Path]:
|
||||
queued = {item.spool_path.name for item in self._queue}
|
||||
return [
|
||||
path
|
||||
for path in self.config.spool.glob("*.png")
|
||||
if not path.is_symlink() and path.name not in queued
|
||||
]
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> VerifiedSpoolReader:
|
||||
return VerifiedSpoolReader(receipt)
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
metadata: FocusedWindowMetadata | None = None,
|
||||
) -> bool:
|
||||
token, digest, size, name = (
|
||||
payload.get("receipt_token"),
|
||||
payload.get("sha256"),
|
||||
payload.get("byte_size"),
|
||||
payload.get("spool_name"),
|
||||
)
|
||||
if (
|
||||
not self.armed
|
||||
or self.paused
|
||||
or len(self._queue) >= self.config.max_queue_items
|
||||
or payload.get("receipt_version") != 1
|
||||
or not isinstance(token, str)
|
||||
or not _ID.fullmatch(token)
|
||||
or token in self._seen_tokens
|
||||
or not isinstance(digest, str)
|
||||
or not _SHA256.fullmatch(digest)
|
||||
or not _integer(size, 8, self.config.max_image_bytes)
|
||||
or payload.get("media_type") != "image/png"
|
||||
or payload.get("policy_version") != self.config.policy_version
|
||||
or not isinstance(name, str)
|
||||
or name != f"{digest}.png"
|
||||
or not isinstance(occurrence_id, str)
|
||||
or not _ID.fullmatch(occurrence_id)
|
||||
or not isinstance(processing_version, str)
|
||||
or not _ID.fullmatch(processing_version)
|
||||
):
|
||||
return False
|
||||
recovered = token in self._recovered_tokens
|
||||
receipt = IntakeReceipt(
|
||||
occurrence_id,
|
||||
token,
|
||||
digest,
|
||||
cast(int, size),
|
||||
"image/png",
|
||||
self.config.spool / name,
|
||||
self.config.policy_version,
|
||||
processing_version,
|
||||
FocusedWindowMetadata(None, None, None)
|
||||
if recovered
|
||||
else focused_window()
|
||||
if metadata is None
|
||||
else metadata,
|
||||
)
|
||||
try:
|
||||
verified_fd = _verify_spool_file(receipt)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
os.close(verified_fd)
|
||||
self._seen_tokens.add(token)
|
||||
self._seen_order.append(token)
|
||||
if len(self._seen_order) > self.config.max_queue_items:
|
||||
self._seen_tokens.remove(self._seen_order.popleft())
|
||||
self._queue.append(receipt)
|
||||
return True
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
removed = reconcile_spool(self.config.spool)
|
||||
capacity = self.config.max_queue_items - len(self._helper_payloads)
|
||||
if capacity <= 0:
|
||||
return removed
|
||||
for payload in durable_receipts(self.config.spool, max_items=256):
|
||||
if len(self._helper_payloads) >= self.config.max_queue_items:
|
||||
break
|
||||
token = payload["receipt_token"]
|
||||
if isinstance(token, str) and token not in self._seen_tokens:
|
||||
self._helper_payloads.append(payload)
|
||||
self._recovered_tokens.add(token)
|
||||
return removed
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
"""Delete this occurrence's durable receipt after repository confirmation only."""
|
||||
if (
|
||||
receipt.spool_path.parent != self.config.spool
|
||||
or receipt.policy_version != self.config.policy_version
|
||||
):
|
||||
return False
|
||||
payload: dict[str, object] = {
|
||||
"receipt_version": 1,
|
||||
"receipt_token": receipt.receipt_token,
|
||||
"sha256": receipt.sha256,
|
||||
"byte_size": receipt.byte_size,
|
||||
"media_type": receipt.media_type,
|
||||
"spool_name": receipt.spool_path.name,
|
||||
"policy_version": receipt.policy_version,
|
||||
}
|
||||
acknowledged = acknowledge_receipt(self.config.spool, payload)
|
||||
if acknowledged:
|
||||
self._recovered_tokens.discard(receipt.receipt_token)
|
||||
return acknowledged
|
||||
|
||||
def _crashed(self, now: float) -> None:
|
||||
if self.armed and not self.paused and self._restart_count < self.config.max_restarts:
|
||||
self._restart_count += 1
|
||||
self._restart_after = now + float(self.config.restart_backoff_seconds) * 2 ** (
|
||||
self._restart_count - 1
|
||||
)
|
||||
else:
|
||||
self._restarts_exhausted = True
|
||||
|
||||
def _start_if_due(self, now: float) -> None:
|
||||
if (
|
||||
not self.armed
|
||||
or self.paused
|
||||
or self._watcher is not None
|
||||
or self._restarts_exhausted
|
||||
or now < self._restart_after
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._watcher = subprocess.Popen(
|
||||
[
|
||||
"wl-paste",
|
||||
"--type",
|
||||
"image/png",
|
||||
"--watch",
|
||||
sys.executable,
|
||||
"-m",
|
||||
"game_data_capture.ingest.spool_helper",
|
||||
"--spool",
|
||||
str(self.config.spool),
|
||||
"--max-bytes",
|
||||
str(self.config.max_image_bytes),
|
||||
"--policy-version",
|
||||
self.config.policy_version,
|
||||
"--media-type",
|
||||
"image/png",
|
||||
],
|
||||
shell=False,
|
||||
env=self.environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
self._crashed(now)
|
||||
return
|
||||
self._reader = threading.Thread(
|
||||
target=self._read_receipts, args=(self._watcher,), daemon=True
|
||||
)
|
||||
self._reader.start()
|
||||
|
||||
def _read_receipts(self, process: subprocess.Popen[bytes]) -> None:
|
||||
if process.stdout is None:
|
||||
return
|
||||
while True:
|
||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
||||
if not line:
|
||||
return
|
||||
if len(line) > _MAX_RECEIPT and not line.endswith(b"\n"):
|
||||
while line and not line.endswith(b"\n"):
|
||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, dict)
|
||||
and all(isinstance(key, str) for key in payload)
|
||||
and len(self._helper_payloads) < self.config.max_queue_items
|
||||
):
|
||||
self._helper_payloads.append(payload)
|
||||
|
||||
def _stop_watcher(self) -> None:
|
||||
process, self._watcher = self._watcher, None
|
||||
if process is None:
|
||||
return
|
||||
if process.poll() is None:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=float(self.config.shutdown_timeout_seconds))
|
||||
except subprocess.TimeoutExpired:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=float(self.config.shutdown_timeout_seconds))
|
||||
self._reader = None
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Small stdin-only PNG spool helper; intentionally independent of application services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISREG
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
RECEIPT_VERSION = 1
|
||||
_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_CHUNK_SIZE = 64 * 1024
|
||||
_MAX_RECEIPT_BYTES = 1024
|
||||
_MAX_SIDECARS = 256
|
||||
_MAX_RECONCILE_ENTRIES = 1024
|
||||
_LOCK_NAME = ".spool.lock"
|
||||
_LOCK_TIMEOUT_SECONDS = 1.0
|
||||
_LOCK_RETRY_SECONDS = 0.01
|
||||
|
||||
|
||||
class SpoolError(ValueError):
|
||||
"""The supplied image or spool boundary is invalid."""
|
||||
|
||||
|
||||
def _private_spool(path: Path) -> None:
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise SpoolError("invalid spool")
|
||||
if path.is_symlink():
|
||||
raise SpoolError("unsafe spool")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if path.is_symlink() or not S_ISDIR(mode):
|
||||
raise SpoolError("unsafe spool")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def _write_all(fd: int, data: bytes) -> None:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise OSError("short spool write")
|
||||
view = view[written:]
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _spool_lock(spool: Path) -> Iterator[None]:
|
||||
"""Hold the private spool lock for one bounded receipt installation."""
|
||||
try:
|
||||
fd = os.open(spool / _LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as error:
|
||||
raise SpoolError("unsafe spool lock") from error
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
||||
raise SpoolError("unsafe spool lock")
|
||||
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SpoolError("spool busy") from None
|
||||
time.sleep(_LOCK_RETRY_SECONDS)
|
||||
yield
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _verify_existing(path: Path, digest: str, size: int) -> bool:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600 or info.st_size != size:
|
||||
return False
|
||||
hasher = hashlib.sha256()
|
||||
prefix = os.read(fd, len(PNG_SIGNATURE))
|
||||
if prefix != PNG_SIGNATURE:
|
||||
return False
|
||||
hasher.update(prefix)
|
||||
while chunk := os.read(fd, _CHUNK_SIZE):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest() == digest
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def receipt_name(token: str) -> str:
|
||||
if not _TOKEN.fullmatch(token):
|
||||
raise SpoolError("invalid receipt identity")
|
||||
return f"{token}.receipt.json"
|
||||
|
||||
|
||||
def _pending_name(token: str) -> str:
|
||||
return f".{token}.receipt.pending"
|
||||
|
||||
|
||||
def _receipt_bytes(receipt: dict[str, object]) -> bytes:
|
||||
encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True).encode("ascii")
|
||||
if len(encoded) > _MAX_RECEIPT_BYTES:
|
||||
raise SpoolError("receipt exceeds limit")
|
||||
return encoded
|
||||
|
||||
|
||||
def _valid_receipt(payload: object, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
||||
if not isinstance(payload, dict) or set(payload) != {
|
||||
"byte_size",
|
||||
"media_type",
|
||||
"policy_version",
|
||||
"receipt_token",
|
||||
"receipt_version",
|
||||
"sha256",
|
||||
"spool_name",
|
||||
}:
|
||||
return None
|
||||
token, digest, size, name = (
|
||||
payload.get("receipt_token"),
|
||||
payload.get("sha256"),
|
||||
payload.get("byte_size"),
|
||||
payload.get("spool_name"),
|
||||
)
|
||||
if (
|
||||
payload.get("receipt_version") != RECEIPT_VERSION
|
||||
or not isinstance(token, str)
|
||||
or not _TOKEN.fullmatch(token)
|
||||
or not isinstance(digest, str)
|
||||
or not _SHA256.fullmatch(digest)
|
||||
or isinstance(size, bool)
|
||||
or not isinstance(size, int)
|
||||
or size < len(PNG_SIGNATURE)
|
||||
or payload.get("media_type") != "image/png"
|
||||
or not isinstance(payload.get("policy_version"), str)
|
||||
or not _TOKEN.fullmatch(payload["policy_version"])
|
||||
or not isinstance(name, str)
|
||||
or name != f"{digest}.png"
|
||||
):
|
||||
return None
|
||||
if verify_png and not _verify_existing(spool / name, digest, size):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_sidecar(path: Path, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if (
|
||||
not S_ISREG(info.st_mode)
|
||||
or (info.st_mode & 0o777) != 0o600
|
||||
or info.st_size > _MAX_RECEIPT_BYTES
|
||||
):
|
||||
return None
|
||||
raw = os.read(fd, _MAX_RECEIPT_BYTES + 1)
|
||||
if len(raw) != info.st_size or len(raw) > _MAX_RECEIPT_BYTES:
|
||||
return None
|
||||
try:
|
||||
return _valid_receipt(json.loads(raw), spool=spool, verify_png=verify_png)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _install_pending_sidecar(spool: Path, receipt: dict[str, object]) -> Path:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
pending = spool / _pending_name(token)
|
||||
encoded = _receipt_bytes(receipt)
|
||||
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
_write_all(fd, encoded)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
# Persist the recoverable pending name before promotion. A crash after this
|
||||
# point cannot claim recovery until its PNG is immutably installed.
|
||||
_fsync_directory(spool)
|
||||
return pending
|
||||
|
||||
|
||||
def _same_receipt(left: dict[str, object], right: dict[str, object]) -> bool:
|
||||
return _receipt_bytes(left) == _receipt_bytes(right)
|
||||
|
||||
|
||||
def _final_sidecar(spool: Path, receipt: dict[str, object]) -> dict[str, object] | None:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
final = spool / receipt_name(token)
|
||||
if not final.exists() and not final.is_symlink():
|
||||
return None
|
||||
stored = _read_sidecar(final, spool=spool, verify_png=True)
|
||||
if stored is None or not _same_receipt(stored, receipt):
|
||||
raise SpoolError("receipt token conflict")
|
||||
return stored
|
||||
|
||||
|
||||
def _receipt_capacity_available(spool: Path) -> bool:
|
||||
"""Count bounded valid receipt sidecars while removing malformed regular entries."""
|
||||
valid = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file():
|
||||
continue
|
||||
pending = entry.name.startswith(".") and entry.name.endswith(".receipt.pending")
|
||||
final = entry.name.endswith(".receipt.json")
|
||||
if not pending and not final:
|
||||
continue
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
return False
|
||||
token = (
|
||||
entry.name[1 : -len(".receipt.pending")]
|
||||
if pending
|
||||
else entry.name[: -len(".receipt.json")]
|
||||
)
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=final)
|
||||
if receipt is None or receipt.get("receipt_token") != token:
|
||||
entry.unlink()
|
||||
_fsync_directory(spool)
|
||||
continue
|
||||
valid += 1
|
||||
if valid >= _MAX_SIDECARS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _promote_sidecar(spool: Path, receipt: dict[str, object], pending: Path) -> None:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
final = spool / receipt_name(token)
|
||||
try:
|
||||
os.link(pending, final, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
try:
|
||||
_final_sidecar(spool, receipt)
|
||||
except SpoolError:
|
||||
pending.unlink()
|
||||
_fsync_directory(spool)
|
||||
raise
|
||||
else:
|
||||
_fsync_directory(spool)
|
||||
pending.unlink()
|
||||
_fsync_directory(spool)
|
||||
|
||||
|
||||
def reconcile_spool(spool: Path) -> list[str]:
|
||||
"""Remove incomplete files and promote only receipt sidecars backed by immutable PNGs."""
|
||||
_private_spool(spool)
|
||||
removed: list[str] = []
|
||||
promoted = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file():
|
||||
continue
|
||||
if entry.name.endswith(".partial"):
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
elif entry.name.startswith(".") and entry.name.endswith(".receipt.pending"):
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
||||
token = entry.name[1 : -len(".receipt.pending")]
|
||||
if receipt is not None and receipt.get("receipt_token") == token:
|
||||
final = spool / receipt_name(token)
|
||||
if final.exists() or final.is_symlink():
|
||||
with suppress(SpoolError):
|
||||
_final_sidecar(spool, receipt)
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
elif promoted >= _MAX_SIDECARS:
|
||||
continue
|
||||
else:
|
||||
_promote_sidecar(spool, receipt, entry)
|
||||
promoted += 1
|
||||
continue
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
return removed
|
||||
|
||||
|
||||
def durable_receipts(spool: Path, *, max_items: int) -> Iterator[dict[str, object]]:
|
||||
"""Yield at most ``max_items`` validated final receipt sidecars without following links."""
|
||||
if (
|
||||
isinstance(max_items, bool)
|
||||
or not isinstance(max_items, int)
|
||||
or not 1 <= max_items <= _MAX_SIDECARS
|
||||
):
|
||||
raise SpoolError("invalid receipt limit")
|
||||
_private_spool(spool)
|
||||
yielded = 0
|
||||
valid = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file() or not entry.name.endswith(".receipt.json"):
|
||||
continue
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
token = entry.name[: -len(".receipt.json")]
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
||||
if receipt is not None and receipt.get("receipt_token") == token:
|
||||
valid += 1
|
||||
if valid > _MAX_SIDECARS:
|
||||
break
|
||||
if yielded < max_items:
|
||||
yielded += 1
|
||||
yield receipt
|
||||
else:
|
||||
try:
|
||||
info = entry.lstat()
|
||||
if S_ISREG(info.st_mode):
|
||||
entry.unlink()
|
||||
_fsync_directory(spool)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def acknowledge_receipt(spool: Path, receipt: dict[str, object]) -> bool:
|
||||
"""Remove exactly one verified durable sidecar; immutable PNG data is never removed here."""
|
||||
_private_spool(spool)
|
||||
token = receipt.get("receipt_token")
|
||||
if not isinstance(token, str) or not _TOKEN.fullmatch(token):
|
||||
return False
|
||||
path = spool / receipt_name(token)
|
||||
stored = _read_sidecar(path, spool=spool, verify_png=True)
|
||||
if stored is None:
|
||||
return False
|
||||
if _receipt_bytes(stored) != _receipt_bytes(receipt):
|
||||
return False
|
||||
try:
|
||||
info = path.lstat()
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
||||
return False
|
||||
path.unlink()
|
||||
_fsync_directory(spool)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def spool_png(
|
||||
input_stream: object,
|
||||
*,
|
||||
spool: Path,
|
||||
max_bytes: int,
|
||||
receipt_token: str,
|
||||
policy_version: str,
|
||||
media_type: str = "image/png",
|
||||
) -> dict[str, object]:
|
||||
"""Install immutable PNG bytes, then fsync a durable occurrence receipt before stdout."""
|
||||
if (
|
||||
isinstance(max_bytes, bool)
|
||||
or not isinstance(max_bytes, int)
|
||||
or not 8 <= max_bytes <= 64 * 1024 * 1024
|
||||
):
|
||||
raise SpoolError("invalid byte limit")
|
||||
if (
|
||||
media_type != "image/png"
|
||||
or not _TOKEN.fullmatch(receipt_token)
|
||||
or not _TOKEN.fullmatch(policy_version)
|
||||
):
|
||||
raise SpoolError("invalid receipt identity")
|
||||
_private_spool(spool)
|
||||
temporary = spool / f".{secrets.token_hex(16)}.partial"
|
||||
pending: Path | None = None
|
||||
immutable_installed = False
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
prefix = bytearray()
|
||||
while True:
|
||||
chunk = input_stream.read(_CHUNK_SIZE) # type: ignore[attr-defined]
|
||||
if not isinstance(chunk, bytes):
|
||||
raise SpoolError("invalid input stream")
|
||||
if not chunk:
|
||||
break
|
||||
prefix.extend(chunk[: max(0, len(PNG_SIGNATURE) - len(prefix))])
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise SpoolError("image exceeds byte limit")
|
||||
hasher.update(chunk)
|
||||
_write_all(fd, chunk)
|
||||
if len(prefix) < len(PNG_SIGNATURE) or bytes(prefix) != PNG_SIGNATURE:
|
||||
raise SpoolError("invalid png")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
digest = hasher.hexdigest()
|
||||
name = f"{digest}.png"
|
||||
receipt: dict[str, object] = {
|
||||
"receipt_version": RECEIPT_VERSION,
|
||||
"receipt_token": receipt_token,
|
||||
"sha256": digest,
|
||||
"byte_size": size,
|
||||
"media_type": "image/png",
|
||||
"spool_name": name,
|
||||
"policy_version": policy_version,
|
||||
}
|
||||
with _spool_lock(spool):
|
||||
if _final_sidecar(spool, receipt) is not None:
|
||||
return receipt
|
||||
if not _receipt_capacity_available(spool):
|
||||
raise SpoolError("spool receipt capacity reached")
|
||||
pending = _install_pending_sidecar(spool, receipt)
|
||||
destination = spool / name
|
||||
if destination.exists():
|
||||
if not _verify_existing(destination, digest, size):
|
||||
raise SpoolError("spool collision")
|
||||
else:
|
||||
os.replace(temporary, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
_fsync_directory(spool)
|
||||
immutable_installed = True
|
||||
_promote_sidecar(spool, receipt, pending)
|
||||
return receipt
|
||||
finally:
|
||||
if temporary.exists() and not temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
if (
|
||||
pending is not None
|
||||
and not immutable_installed
|
||||
and pending.exists()
|
||||
and not pending.is_symlink()
|
||||
):
|
||||
pending.unlink()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--spool", required=True)
|
||||
parser.add_argument("--max-bytes", required=True, type=int)
|
||||
parser.add_argument("--receipt-token", default=secrets.token_hex(16))
|
||||
parser.add_argument("--policy-version", required=True)
|
||||
parser.add_argument("--media-type", default="image/png")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
receipt = spool_png(
|
||||
sys.stdin.buffer,
|
||||
spool=Path(args.spool),
|
||||
max_bytes=args.max_bytes,
|
||||
receipt_token=args.receipt_token,
|
||||
policy_version=args.policy_version,
|
||||
media_type=args.media_type,
|
||||
)
|
||||
except (OSError, SpoolError):
|
||||
return 2
|
||||
sys.stdout.write(json.dumps(receipt, separators=(",", ":")) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Structured logging helpers that remove sensitive capture material."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
_SENSITIVE_FIELD = re.compile(
|
||||
r"(?:api[_-]?key|authorization|auth[_-]?header|token|secret|password|"
|
||||
r"image|payload|base64|data[_-]?url|ocr[_-]?text|recognized[_-]?text)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AUTHORIZATION_VALUE = re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?\S+")
|
||||
_KEY_VALUE = re.compile(r"(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+")
|
||||
_OPENAI_KEY = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
"""Remove common secret-bearing fragments from a log message."""
|
||||
|
||||
value = _AUTHORIZATION_VALUE.sub(r"\1[REDACTED]", value)
|
||||
value = _KEY_VALUE.sub(r"\1[REDACTED]", value)
|
||||
return _OPENAI_KEY.sub("[REDACTED]", value)
|
||||
|
||||
|
||||
def redact_log_data(value: object, field_name: str | None = None) -> object:
|
||||
"""Redact secrets, image data, and unbounded OCR text from structured fields."""
|
||||
|
||||
if field_name is not None and _SENSITIVE_FIELD.search(field_name):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): redact_log_data(item, str(key)) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple | set | frozenset):
|
||||
return [redact_log_data(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return redact_text(value[:512])
|
||||
return value
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""Formatter for static messages plus redacted structured event data."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
original_message = record.msg
|
||||
original_args = record.args
|
||||
original_exception = record.exc_info
|
||||
original_exception_text = record.exc_text
|
||||
original_stack = record.stack_info
|
||||
try:
|
||||
if isinstance(record.msg, str):
|
||||
record.msg = redact_text(record.msg)
|
||||
if isinstance(record.args, Mapping):
|
||||
record.args = {
|
||||
str(key): redact_log_data(value, str(key)) for key, value in record.args.items()
|
||||
}
|
||||
elif record.args:
|
||||
record.args = tuple(redact_log_data(item) for item in record.args)
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
record.stack_info = None
|
||||
output = redact_text(super().format(record))
|
||||
if original_exception is not None or original_exception_text is not None:
|
||||
return f"{output}\n[REDACTED EXCEPTION]"
|
||||
return output
|
||||
finally:
|
||||
record.msg = original_message
|
||||
record.args = original_args
|
||||
record.exc_info = original_exception
|
||||
record.exc_text = original_exception_text
|
||||
record.stack_info = original_stack
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> logging.Logger:
|
||||
"""Create an isolated logger that writes redacted records to stderr."""
|
||||
|
||||
logger = logging.getLogger("game_data_capture")
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(RedactingFormatter("%(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
|
||||
"""Log a stable event name and scrubbed structured fields.
|
||||
|
||||
Callers must keep event names static and place external text in named fields.
|
||||
"""
|
||||
|
||||
logger.info("%s %s", event, redact_log_data(fields))
|
||||
@@ -0,0 +1,108 @@
|
||||
"""XDG path resolution and private-directory preparation.
|
||||
|
||||
Path resolution is pure. Call :func:`prepare_private_directories` only at a runtime
|
||||
boundary that is allowed to create the utility's private directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISLNK, S_ISREG
|
||||
|
||||
|
||||
class PathConfigurationError(ValueError):
|
||||
"""A configured path violates the local runtime contract."""
|
||||
|
||||
|
||||
def _bounded_path_error(kind: str) -> PathConfigurationError:
|
||||
return PathConfigurationError(f"{kind} path must be an absolute path within 240 characters")
|
||||
|
||||
|
||||
def _read_xdg_path(environment: Mapping[str, str], key: str, fallback: Path, kind: str) -> Path:
|
||||
raw_value = environment.get(key)
|
||||
candidate = fallback if raw_value is None or raw_value == "" else Path(raw_value).expanduser()
|
||||
if not candidate.is_absolute() or len(str(candidate)) > 240:
|
||||
raise _bounded_path_error(kind)
|
||||
return candidate
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimePaths:
|
||||
"""Private filesystem locations owned by one local capture installation."""
|
||||
|
||||
config_dir: Path
|
||||
data_dir: Path
|
||||
cache_dir: Path
|
||||
runtime_dir: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for path in (self.config_dir, self.data_dir, self.cache_dir, self.runtime_dir):
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise _bounded_path_error("runtime")
|
||||
|
||||
@property
|
||||
def config_file(self) -> Path:
|
||||
return self.config_dir / "config.toml"
|
||||
|
||||
@property
|
||||
def instance_lock_file(self) -> Path:
|
||||
return self.runtime_dir / "instance.lock"
|
||||
|
||||
|
||||
def resolve_runtime_paths(environment: Mapping[str, str] | None = None) -> RuntimePaths:
|
||||
"""Resolve XDG paths without creating or inspecting filesystem entries."""
|
||||
|
||||
env = environ if environment is None else environment
|
||||
home = Path(env.get("HOME", "~")).expanduser()
|
||||
if not home.is_absolute() or len(str(home)) > 200:
|
||||
raise _bounded_path_error("home")
|
||||
|
||||
config_root = _read_xdg_path(env, "XDG_CONFIG_HOME", home / ".config", "XDG config")
|
||||
data_root = _read_xdg_path(env, "XDG_DATA_HOME", home / ".local" / "share", "XDG data")
|
||||
cache_root = _read_xdg_path(env, "XDG_CACHE_HOME", home / ".cache", "XDG cache")
|
||||
runtime_root = _read_xdg_path(env, "XDG_RUNTIME_DIR", data_root / "runtime", "XDG runtime")
|
||||
|
||||
return RuntimePaths(
|
||||
config_dir=config_root / "shd-capture",
|
||||
data_dir=data_root / "shd-capture",
|
||||
cache_dir=cache_root / "shd-capture",
|
||||
runtime_dir=runtime_root / "shd-capture",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
||||
raise PathConfigurationError("runtime path must name a directory")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def prepare_private_directories(paths: RuntimePaths) -> None:
|
||||
"""Create utility directories with owner-only access at an explicit runtime boundary."""
|
||||
|
||||
for path in (paths.config_dir, paths.data_dir, paths.cache_dir, paths.runtime_dir):
|
||||
_ensure_private_directory(path)
|
||||
|
||||
|
||||
def prepare_private_file(path: Path) -> None:
|
||||
"""Create a runtime-owned regular file with owner-only access.
|
||||
|
||||
The lifecycle layer uses this contract for its instance lock and any future
|
||||
state files. Callers must prepare the parent directory first.
|
||||
"""
|
||||
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise _bounded_path_error("runtime")
|
||||
if path.is_symlink():
|
||||
raise PathConfigurationError("runtime file must be a regular file")
|
||||
if path.exists():
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISREG(mode):
|
||||
raise PathConfigurationError("runtime file must be a regular file")
|
||||
else:
|
||||
path.touch(mode=0o600, exist_ok=False)
|
||||
path.chmod(0o600)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Bounded durable leases that follow the frozen job state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from game_data_capture.domain.state_machines import require_transition
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError, _id
|
||||
|
||||
_KINDS = {"classify", "ocr", "vision_fallback", "frame_extract", "export"}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _lease(seconds: int) -> None:
|
||||
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= 300:
|
||||
raise ValueError("lease duration must be from 1 to 300 seconds")
|
||||
|
||||
|
||||
class JobLeases:
|
||||
def __init__(self, database: StorageDatabase) -> None:
|
||||
self.database = database
|
||||
|
||||
def create(
|
||||
self, job_id: str, kind: str, capture_id: str | None = None, max_attempts: int = 3
|
||||
) -> None:
|
||||
_id(job_id)
|
||||
if capture_id is not None:
|
||||
_id(capture_id)
|
||||
if (
|
||||
kind not in _KINDS
|
||||
or isinstance(max_attempts, bool)
|
||||
or not isinstance(max_attempts, int)
|
||||
or not 1 <= max_attempts <= 20
|
||||
):
|
||||
raise ValueError("invalid job definition")
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
|
||||
"VALUES (?, ?, ?, 'queued', ?)",
|
||||
(job_id, capture_id, kind, max_attempts),
|
||||
)
|
||||
|
||||
def _recover_expired(self, connection: sqlite3.Connection, now: datetime) -> None:
|
||||
# State changes use the contract path: running -> retry_wait -> queued/failed.
|
||||
rows = list(
|
||||
connection.execute(
|
||||
"SELECT job_id, attempt, max_attempts FROM jobs "
|
||||
"WHERE state = 'running' AND lease_expires_at < ?",
|
||||
(now.isoformat(),),
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
require_transition("job", "running", "retry_wait")
|
||||
changed = connection.execute(
|
||||
"UPDATE jobs SET state = 'retry_wait', lease_owner = NULL, "
|
||||
"lease_expires_at = NULL, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'running'",
|
||||
(row["job_id"],),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise StorageError("expired job changed concurrently")
|
||||
target = "failed" if row["attempt"] >= row["max_attempts"] else "queued"
|
||||
require_transition("job", "retry_wait", target)
|
||||
changed = connection.execute(
|
||||
"UPDATE jobs SET state = ?, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'retry_wait'",
|
||||
(target, row["job_id"]),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise StorageError("expired job changed concurrently")
|
||||
|
||||
def acquire(self, owner: str, lease_seconds: int) -> str | None:
|
||||
_id(owner)
|
||||
_lease(lease_seconds)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._recover_expired(connection, now)
|
||||
row = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE state = 'queued' "
|
||||
"AND attempt < max_attempts ORDER BY job_id LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("COMMIT")
|
||||
return None
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET state = 'running', lease_owner = ?, lease_expires_at = ?, "
|
||||
"attempt = attempt + 1, revision = revision + 1 WHERE job_id = ? "
|
||||
"AND state = 'queued' AND attempt < max_attempts",
|
||||
(owner, (now + timedelta(seconds=lease_seconds)).isoformat(), row["job_id"]),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return str(row["job_id"]) if result.rowcount == 1 else None
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def renew(self, job_id: str, owner: str, lease_seconds: int) -> None:
|
||||
_id(job_id)
|
||||
_id(owner)
|
||||
_lease(lease_seconds)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET lease_expires_at = ?, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'running' AND lease_owner = ? "
|
||||
"AND lease_expires_at >= ?",
|
||||
(
|
||||
(now + timedelta(seconds=lease_seconds)).isoformat(),
|
||||
job_id,
|
||||
owner,
|
||||
now.isoformat(),
|
||||
),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("lease is not owned or has expired")
|
||||
|
||||
def finish(self, job_id: str, owner: str, target: str) -> None:
|
||||
_id(job_id)
|
||||
_id(owner)
|
||||
if target not in {"succeeded", "failed", "cancelled"}:
|
||||
raise ValueError("job finish target must be terminal")
|
||||
require_transition("job", "running", target)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET state = ?, lease_owner = NULL, lease_expires_at = NULL, "
|
||||
"revision = revision + 1 WHERE job_id = ? AND state = 'running' "
|
||||
"AND lease_owner = ? AND lease_expires_at >= ?",
|
||||
(target, job_id, owner, now.isoformat()),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("lease is not owned or has expired")
|
||||
|
||||
def recover(self) -> int:
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
before = connection.execute(
|
||||
"SELECT count(*) FROM jobs WHERE state = 'running' AND lease_expires_at < ?",
|
||||
(now.isoformat(),),
|
||||
).fetchone()[0]
|
||||
self._recover_expired(connection, now)
|
||||
connection.execute("COMMIT")
|
||||
return int(before)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Bounded lifecycle wiring for local capture intake and utility storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping
|
||||
from contextlib import AbstractContextManager, contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event, RLock, Thread, current_thread, main_thread
|
||||
from typing import Protocol, cast
|
||||
|
||||
import uvicorn
|
||||
|
||||
from game_data_capture.config import AppConfig
|
||||
from game_data_capture.ingest.clipboard import ClipboardIntake, IntakeReceipt, WatchConfig
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
|
||||
from game_data_capture.paths import RuntimePaths, prepare_private_directories, prepare_private_file
|
||||
from game_data_capture.pipeline.jobs import JobLeases
|
||||
from game_data_capture.storage import (
|
||||
CaptureCorpus,
|
||||
CaptureRepository,
|
||||
ContentAddressedBlobStore,
|
||||
StorageDatabase,
|
||||
)
|
||||
from game_data_capture.web.app import (
|
||||
CaptureControlPort,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
_POLICY_VERSION = "capture-v1"
|
||||
_PROCESSING_VERSION = "capture-v1"
|
||||
_POLL_SECONDS = 0.1
|
||||
|
||||
|
||||
class RuntimeFailure(RuntimeError):
|
||||
"""A safe-to-display runtime failure that does not disclose local inputs."""
|
||||
|
||||
|
||||
class IntakePort(Protocol):
|
||||
"""The accepted media descriptor boundary consumed by this runtime."""
|
||||
|
||||
def reconcile(self) -> list[str]: ...
|
||||
|
||||
def arm(self) -> None: ...
|
||||
|
||||
def tick(self) -> None: ...
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None: ...
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
) -> bool: ...
|
||||
|
||||
def take(self) -> IntakeReceipt | None: ...
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]: ...
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool: ...
|
||||
|
||||
def shutdown(self) -> None: ...
|
||||
|
||||
|
||||
class InstanceLock:
|
||||
"""Descriptor-held ownership that safely reuses an unlocked stale lock file."""
|
||||
|
||||
def __init__(self, file_descriptor: int) -> None:
|
||||
self._file_descriptor: int | None = file_descriptor
|
||||
|
||||
@classmethod
|
||||
def acquire(cls, path: Path, timeout_seconds: int) -> InstanceLock:
|
||||
try:
|
||||
prepare_private_file(path)
|
||||
descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError as error:
|
||||
raise RuntimeFailure("capture instance lock is unavailable") from error
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise RuntimeFailure("capture instance lock is unsafe")
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return cls(descriptor)
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeFailure("another capture instance is active") from None
|
||||
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
if self._file_descriptor is None:
|
||||
return
|
||||
descriptor, self._file_descriptor = self._file_descriptor, None
|
||||
with suppress(OSError):
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Resources:
|
||||
repository: CaptureRepository
|
||||
corpus: CaptureCorpus
|
||||
lock: InstanceLock
|
||||
|
||||
def close(self) -> None:
|
||||
self.lock.release()
|
||||
|
||||
|
||||
class _CaptureControl(CaptureControlPort):
|
||||
"""Serialize one optional armed intake beneath a running local service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: CaptureRuntime,
|
||||
repository: CaptureRepository,
|
||||
) -> None:
|
||||
self._runtime = runtime
|
||||
self._repository = repository
|
||||
self._lock = RLock()
|
||||
self._cancelled: Event | None = None
|
||||
self._intake: IntakePort | None = None
|
||||
self._worker: Thread | None = None
|
||||
self._state = "disarmed"
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
intake = self._intake
|
||||
depth = self._queue_depth(intake)
|
||||
return CaptureSessionStatus(
|
||||
armed=self._state in {"armed", "stopping"},
|
||||
queue_depth=depth,
|
||||
queue_capacity=self._runtime.config.limits.max_queue_items,
|
||||
)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
if self._state == "armed":
|
||||
return "already_armed"
|
||||
if self._state == "stopping":
|
||||
return "conflict"
|
||||
if self._worker is not None:
|
||||
return "conflict"
|
||||
intake: IntakePort | None = None
|
||||
try:
|
||||
intake = self._runtime.intake_factory(self._runtime._watch_config())
|
||||
# Durable media always becomes eligible before the watcher is armed.
|
||||
intake.reconcile()
|
||||
intake.arm()
|
||||
except Exception:
|
||||
if intake is not None:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
self._state = "disarmed"
|
||||
return "conflict"
|
||||
assert intake is not None
|
||||
cancelled = Event()
|
||||
worker = Thread(
|
||||
target=self._drain_loop,
|
||||
args=(intake, cancelled),
|
||||
name="shd-capture-drain",
|
||||
daemon=True,
|
||||
)
|
||||
self._intake = intake
|
||||
self._cancelled = cancelled
|
||||
self._worker = worker
|
||||
self._state = "armed"
|
||||
try:
|
||||
worker.start()
|
||||
except Exception:
|
||||
self._state = "disarmed"
|
||||
self._intake = self._cancelled = self._worker = None
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
return "conflict"
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
return "already_stopped"
|
||||
if self._state == "stopping":
|
||||
return "conflict"
|
||||
self._state = "stopping"
|
||||
cancelled = self._cancelled
|
||||
intake = self._intake
|
||||
return "stopped" if self._stop_worker(worker, intake, cancelled) else "conflict"
|
||||
|
||||
def shutdown(self) -> bool:
|
||||
"""Request child shutdown before the service gives up its instance lock."""
|
||||
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
return True
|
||||
self._state = "stopping"
|
||||
intake = self._intake
|
||||
cancelled = self._cancelled
|
||||
return self._stop_worker(worker, intake, cancelled)
|
||||
|
||||
def defer_resource_close(self, close: Callable[[], None]) -> None:
|
||||
"""Release the service lock only after a timed-out drain worker exits."""
|
||||
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
close()
|
||||
return
|
||||
|
||||
def wait_for_worker() -> None:
|
||||
worker.join()
|
||||
close()
|
||||
|
||||
Thread(target=wait_for_worker, name="shd-capture-release", daemon=True).start()
|
||||
|
||||
def _stop_worker(
|
||||
self,
|
||||
worker: Thread,
|
||||
intake: IntakePort | None,
|
||||
cancelled: Event | None,
|
||||
) -> bool:
|
||||
if cancelled is not None:
|
||||
cancelled.set()
|
||||
timeout = float(self._runtime.config.lifecycle.shutdown_timeout_seconds)
|
||||
initial_timeout = timeout / 2
|
||||
# Never hold the control lock while waiting for a drain or child cleanup.
|
||||
worker.join(timeout=initial_timeout)
|
||||
if worker.is_alive():
|
||||
# A receipt in flight remains durable because cancellation prevents its
|
||||
# acknowledgement. Stopping intake unblocks watcher/helper cleanup.
|
||||
if intake is not None:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
worker.join(timeout=timeout - initial_timeout)
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
return not worker.is_alive()
|
||||
|
||||
def _drain_loop(self, intake: IntakePort, cancelled: Event) -> None:
|
||||
try:
|
||||
while not cancelled.is_set():
|
||||
intake.tick()
|
||||
self._runtime._drain(intake, self._repository, cancelled.is_set)
|
||||
cancelled.wait(_POLL_SECONDS)
|
||||
except Exception:
|
||||
# The HTTP boundary exposes only bounded armed/queue state, not an
|
||||
# intake, storage, helper, or media exception.
|
||||
pass
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
with self._lock:
|
||||
if self._intake is intake:
|
||||
self._state = "disarmed"
|
||||
|
||||
def _reap_finished_worker(self) -> None:
|
||||
if self._worker is not None and not self._worker.is_alive():
|
||||
self._worker = self._cancelled = self._intake = None
|
||||
self._state = "disarmed"
|
||||
|
||||
def _queue_depth(self, intake: IntakePort | None) -> int:
|
||||
if intake is None:
|
||||
return 0
|
||||
depths: list[int] = []
|
||||
for name in ("queue_depth", "helper_queue_depth"):
|
||||
try:
|
||||
value = getattr(intake, name, 0)
|
||||
except Exception:
|
||||
value = 0
|
||||
if not isinstance(value, bool) and isinstance(value, int) and value >= 0:
|
||||
depths.append(value)
|
||||
return min(sum(depths), self._runtime.config.limits.max_queue_items)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _verified_png_file(path: Path, max_bytes: int) -> Iterator[Iterable[bytes]]:
|
||||
"""Yield a one-descriptor PNG stream and reject unsafe input without path disclosure."""
|
||||
|
||||
candidate = path.absolute()
|
||||
if len(str(candidate)) > 240:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
try:
|
||||
descriptor = os.open(candidate, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError as error:
|
||||
raise RuntimeFailure("input image is not a safe PNG") from error
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or stat.S_IMODE(info.st_mode) & 0o022
|
||||
or not 8 <= info.st_size <= max_bytes
|
||||
):
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
|
||||
def chunks() -> Iterator[bytes]:
|
||||
first = os.read(descriptor, len(PNG_SIGNATURE))
|
||||
if first != PNG_SIGNATURE:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
total = len(first)
|
||||
yield first
|
||||
while chunk := os.read(descriptor, 64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
yield chunk
|
||||
if total != info.st_size:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
|
||||
yield chunks()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class CaptureRuntime:
|
||||
"""Own utility-local storage, then stream verified media receipts into it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
paths: RuntimePaths,
|
||||
*,
|
||||
migrations_dir: Path | None = None,
|
||||
intake_factory: Callable[[WatchConfig], IntakePort] = ClipboardIntake,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.paths = paths
|
||||
self.migrations_dir = (
|
||||
Path(__file__).resolve().parents[2] / "migrations"
|
||||
if migrations_dir is None
|
||||
else migrations_dir
|
||||
)
|
||||
self.intake_factory = intake_factory
|
||||
|
||||
def _watch_config(self) -> WatchConfig:
|
||||
return WatchConfig(
|
||||
spool=self.paths.cache_dir / "spool",
|
||||
max_image_bytes=self.config.limits.max_image_bytes,
|
||||
max_queue_items=self.config.limits.max_queue_items,
|
||||
policy_version=_POLICY_VERSION,
|
||||
shutdown_timeout_seconds=float(min(30, self.config.lifecycle.shutdown_timeout_seconds)),
|
||||
)
|
||||
|
||||
def validate_plan(self) -> None:
|
||||
"""Validate static configuration and paths without creating runtime resources."""
|
||||
|
||||
self._watch_config()
|
||||
if not self.migrations_dir.is_absolute() or not self.migrations_dir.is_dir():
|
||||
raise RuntimeFailure("capture runtime resources are unavailable")
|
||||
StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
||||
ContentAddressedBlobStore(self.paths.data_dir)
|
||||
|
||||
def _start(self) -> _Resources:
|
||||
self.validate_plan()
|
||||
prepare_private_directories(self.paths)
|
||||
lock = InstanceLock.acquire(
|
||||
self.paths.instance_lock_file, self.config.lifecycle.lock_timeout_seconds
|
||||
)
|
||||
try:
|
||||
database = StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
||||
database.migrate()
|
||||
blobs = ContentAddressedBlobStore(self.paths.data_dir)
|
||||
blobs.reconcile()
|
||||
JobLeases(database).recover()
|
||||
corpus = CaptureCorpus(
|
||||
database,
|
||||
blobs,
|
||||
max_media_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
corpus.reconcile_deletions()
|
||||
return _Resources(CaptureRepository(database, blobs), corpus, lock)
|
||||
except RuntimeFailure:
|
||||
lock.release()
|
||||
raise
|
||||
except Exception as error:
|
||||
lock.release()
|
||||
raise RuntimeFailure("capture runtime initialization failed") from error
|
||||
|
||||
def ingest_image(self, image: Path) -> str:
|
||||
"""Persist one descriptor-verified PNG as a unique capture occurrence."""
|
||||
|
||||
resources = self._start()
|
||||
token = secrets.token_hex(16)
|
||||
try:
|
||||
with _verified_png_file(image, self.config.limits.max_image_bytes) as stream:
|
||||
return resources.repository.receive(
|
||||
f"capture-{token}",
|
||||
f"image-{token}",
|
||||
stream,
|
||||
media_type="image/png",
|
||||
max_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("image capture could not be stored") from error
|
||||
finally:
|
||||
resources.close()
|
||||
|
||||
def _drain(
|
||||
self,
|
||||
intake: IntakePort,
|
||||
repository: CaptureRepository,
|
||||
cancellation_requested: Callable[[], bool],
|
||||
) -> None:
|
||||
while (
|
||||
not cancellation_requested() and (payload := intake.take_helper_receipt()) is not None
|
||||
):
|
||||
intake.accept_helper_receipt(
|
||||
payload,
|
||||
occurrence_id=f"capture-{secrets.token_hex(16)}",
|
||||
processing_version=_PROCESSING_VERSION,
|
||||
)
|
||||
while not cancellation_requested() and (receipt := intake.take()) is not None:
|
||||
with intake.open_verified(receipt) as reader:
|
||||
repository.receive(
|
||||
receipt.occurrence_id,
|
||||
receipt.receipt_token,
|
||||
reader,
|
||||
media_type="image/png",
|
||||
max_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
if cancellation_requested():
|
||||
return
|
||||
if not intake.acknowledge(receipt):
|
||||
raise RuntimeFailure("capture receipt acknowledgement failed")
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Run one local web service with unarmed, durable capture control."""
|
||||
|
||||
bootstrap_token = new_bootstrap_token()
|
||||
resources = self._start()
|
||||
control = _CaptureControl(self, resources.repository)
|
||||
authority = (
|
||||
f"[{self.config.server.host}]"
|
||||
if ":" in self.config.server.host
|
||||
else self.config.server.host
|
||||
)
|
||||
try:
|
||||
app = create_app(
|
||||
config=WebConfig(
|
||||
server=self.config.server,
|
||||
max_capture_items=min(64, self.config.limits.max_queue_items),
|
||||
max_image_bytes=self.config.limits.max_image_bytes,
|
||||
),
|
||||
capture_control=control,
|
||||
corpus=resources.corpus,
|
||||
bootstrap_token=bootstrap_token,
|
||||
)
|
||||
print(
|
||||
f"Open local review service: http://{authority}:{self.config.server.port}/"
|
||||
f"#bootstrap={bootstrap_token}"
|
||||
)
|
||||
print("H1: capture is disarmed; use Start Session before taking a screenshot.")
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port,
|
||||
reload=False,
|
||||
workers=1,
|
||||
proxy_headers=False,
|
||||
)
|
||||
except SystemExit as error:
|
||||
if error.code not in (None, 0):
|
||||
raise RuntimeFailure("capture service failed") from error
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("capture service failed") from error
|
||||
finally:
|
||||
if control.shutdown():
|
||||
resources.close()
|
||||
else:
|
||||
control.defer_resource_close(resources.close)
|
||||
raise RuntimeFailure("capture service shutdown failed")
|
||||
|
||||
def watch(self, *, cancelled: Event | None = None, poll_seconds: float = _POLL_SECONDS) -> None:
|
||||
"""Run the armed intake until cancellation, signal, or keyboard interruption."""
|
||||
|
||||
if not 0 < poll_seconds <= 1:
|
||||
raise ValueError("invalid runtime poll interval")
|
||||
resources = self._start()
|
||||
intake: IntakePort | None = None
|
||||
stop_requested = Event()
|
||||
handlers: dict[signal.Signals, object] = {}
|
||||
|
||||
def request_stop(_signal_number: int, _frame: object) -> None:
|
||||
stop_requested.set()
|
||||
|
||||
try:
|
||||
if current_thread() is main_thread():
|
||||
for configured_signal in (signal.SIGINT, signal.SIGTERM):
|
||||
handlers[configured_signal] = signal.signal(configured_signal, request_stop)
|
||||
intake = self.intake_factory(self._watch_config())
|
||||
intake.reconcile()
|
||||
intake.arm()
|
||||
while not stop_requested.is_set() and (cancelled is None or not cancelled.is_set()):
|
||||
intake.tick()
|
||||
self._drain(
|
||||
intake,
|
||||
resources.repository,
|
||||
lambda: (
|
||||
stop_requested.is_set() or (cancelled is not None and cancelled.is_set())
|
||||
),
|
||||
)
|
||||
time.sleep(poll_seconds)
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("capture watch failed") from error
|
||||
finally:
|
||||
if intake is not None:
|
||||
with suppress(OSError, RuntimeError):
|
||||
intake.shutdown()
|
||||
for restored_signal, handler in handlers.items():
|
||||
signal.signal(restored_signal, cast(signal.Handlers, handler))
|
||||
resources.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Utility-local durable storage; it never opens the SHD.build application database."""
|
||||
|
||||
from game_data_capture.storage.blobs import BlobCorruptionError, ContentAddressedBlobStore
|
||||
from game_data_capture.storage.corpus import CaptureCorpus
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import CaptureRepository, StorageError
|
||||
|
||||
__all__ = [
|
||||
"BlobCorruptionError",
|
||||
"CaptureCorpus",
|
||||
"CaptureRepository",
|
||||
"ContentAddressedBlobStore",
|
||||
"StorageDatabase",
|
||||
"StorageError",
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Verified immutable blobs with no symlink traversal at storage boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Iterable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from stat import S_IMODE, S_ISDIR, S_ISREG
|
||||
|
||||
|
||||
class BlobCorruptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BlobLimitError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BlobLockError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _digest(value: str) -> None:
|
||||
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
||||
raise ValueError("invalid SHA-256 digest")
|
||||
|
||||
|
||||
class ContentAddressedBlobStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not root.is_absolute():
|
||||
raise ValueError("blob root must be absolute")
|
||||
self.root = root
|
||||
self.blobs = root / "blobs"
|
||||
self.temporary = root / "tmp"
|
||||
self.lock_path = root / ".blob-operation.lock"
|
||||
|
||||
@contextmanager
|
||||
def operation_lock(self, *, timeout_seconds: float = 1.0) -> Iterator[None]:
|
||||
"""Hold the private cross-process lock for receipt finalization or deletion."""
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, int | float)
|
||||
or not 0 < timeout_seconds <= 5
|
||||
):
|
||||
raise ValueError("blob lock timeout is invalid")
|
||||
self._directory(self.root, create=True)
|
||||
try:
|
||||
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as error:
|
||||
raise BlobLockError("blob operation lock is unavailable") from error
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or S_IMODE(info.st_mode) != 0o600:
|
||||
raise BlobLockError("blob operation lock is unsafe")
|
||||
import fcntl
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise BlobLockError("blob operation lock is unavailable") from None
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise BlobLockError("blob operation lock is unavailable") from None
|
||||
time.sleep(min(0.02, remaining))
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _directory(self, path: Path, *, create: bool) -> None:
|
||||
if path.is_symlink():
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
if create:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if not path.exists() or path.is_symlink() or not S_ISDIR(path.lstat().st_mode):
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
path.chmod(0o700)
|
||||
|
||||
def _path(self, digest: str) -> Path:
|
||||
_digest(digest)
|
||||
return self.blobs / digest[:2] / digest[2:]
|
||||
|
||||
def _fsync_directory(self, path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _verify_fd(self, fd: int, digest: str, expected_size: int | None) -> int:
|
||||
stat = os.fstat(fd)
|
||||
if not S_ISREG(stat.st_mode):
|
||||
raise BlobCorruptionError("blob is not a regular file")
|
||||
if expected_size is not None and stat.st_size != expected_size:
|
||||
raise BlobCorruptionError("blob content does not match receipt")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
size += len(chunk)
|
||||
hasher.update(chunk)
|
||||
if hasher.hexdigest() != digest or (expected_size is not None and size != expected_size):
|
||||
raise BlobCorruptionError("blob content does not match receipt")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
return size
|
||||
|
||||
def put(
|
||||
self, chunks: Iterable[bytes], *, max_bytes: int, expected_sha256: str | None = None
|
||||
) -> tuple[str, int]:
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||
raise BlobLimitError("blob limit must be positive")
|
||||
if expected_sha256 is not None:
|
||||
_digest(expected_sha256)
|
||||
self._directory(self.root, create=True)
|
||||
self._directory(self.blobs, create=True)
|
||||
self._directory(self.temporary, create=True)
|
||||
temporary = self.temporary / f".{secrets.token_hex(16)}.partial"
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
for chunk in chunks:
|
||||
if not isinstance(chunk, bytes) or not chunk:
|
||||
raise BlobLimitError("blob chunks must be non-empty bytes")
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise BlobLimitError("blob exceeds configured size limit")
|
||||
hasher.update(chunk)
|
||||
view = memoryview(chunk)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise OSError("short blob write")
|
||||
view = view[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
digest = hasher.hexdigest()
|
||||
if size == 0 or (expected_sha256 is not None and digest != expected_sha256):
|
||||
raise BlobCorruptionError("blob digest verification failed")
|
||||
with self.operation_lock():
|
||||
destination = self._path(digest)
|
||||
new_prefix = not destination.parent.exists()
|
||||
self._directory(destination.parent, create=True)
|
||||
if new_prefix:
|
||||
self._fsync_directory(self.blobs)
|
||||
directory_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
if destination.exists():
|
||||
self.verify(digest, expected_size=size)
|
||||
else:
|
||||
os.replace(temporary, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
self.verify(digest, expected_size=size)
|
||||
return digest, size
|
||||
finally:
|
||||
if temporary.exists() and not temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
|
||||
def _open_verified(self, digest: str, expected_size: int | None = None) -> tuple[int, int]:
|
||||
path = self._path(digest)
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError as error:
|
||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
||||
try:
|
||||
return fd, self._verify_fd(fd, digest, expected_size)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
def verify(self, digest: str, *, expected_size: int | None = None) -> int:
|
||||
fd, size = self._open_verified(digest, expected_size)
|
||||
os.close(fd)
|
||||
return size
|
||||
|
||||
def read(
|
||||
self, digest: str, *, max_bytes: int, expected_size: int | None = None
|
||||
) -> Iterator[bytes]:
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||
raise BlobLimitError("blob limit must be positive")
|
||||
fd, size = self._open_verified(digest, expected_size)
|
||||
if size > max_bytes:
|
||||
os.close(fd)
|
||||
raise BlobLimitError("blob exceeds read limit")
|
||||
try:
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def delete_verified(self, digest: str, *, expected_size: int) -> bool:
|
||||
"""Unlink one verified immutable blob without following a replacement path."""
|
||||
if (
|
||||
isinstance(expected_size, bool)
|
||||
or not isinstance(expected_size, int)
|
||||
or expected_size <= 0
|
||||
):
|
||||
raise BlobLimitError("blob size is invalid")
|
||||
fd, _ = self._open_verified(digest, expected_size)
|
||||
path = self._path(digest)
|
||||
try:
|
||||
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
opened = os.fstat(fd)
|
||||
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
||||
raise BlobCorruptionError("blob changed during deletion")
|
||||
os.unlink(path.name, dir_fd=parent_fd)
|
||||
os.fsync(parent_fd)
|
||||
return True
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
except OSError as error:
|
||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
if not self.temporary.exists() or self.temporary.is_symlink():
|
||||
return []
|
||||
self._directory(self.temporary, create=False)
|
||||
removed = []
|
||||
for entry in self.temporary.iterdir():
|
||||
if not entry.is_symlink() and entry.is_file() and entry.name.endswith(".partial"):
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
return removed
|
||||
|
||||
def purge_dry_run(self, digests: set[str]) -> list[Path]:
|
||||
if not self.blobs.exists() or self.blobs.is_symlink():
|
||||
return []
|
||||
self._directory(self.blobs, create=False)
|
||||
candidates = []
|
||||
for prefix in self.blobs.iterdir():
|
||||
if prefix.is_symlink() or not prefix.is_dir():
|
||||
continue
|
||||
for entry in prefix.iterdir():
|
||||
if (
|
||||
not entry.is_symlink()
|
||||
and entry.is_file()
|
||||
and prefix.name + entry.name not in digests
|
||||
):
|
||||
candidates.append(entry.relative_to(self.root))
|
||||
return candidates
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Durable, safe projections for the reviewed local capture corpus port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from game_data_capture.domain.validation import (
|
||||
ContractValidationError,
|
||||
validate_classification,
|
||||
)
|
||||
from game_data_capture.storage.blobs import (
|
||||
BlobCorruptionError,
|
||||
BlobLockError,
|
||||
ContentAddressedBlobStore,
|
||||
)
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError, _now
|
||||
from game_data_capture.web.app import CaptureRecord, MediaDescriptor, MutationOutcome
|
||||
|
||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
||||
_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
||||
_CLASSIFICATION_KEYS = frozenset(
|
||||
{"family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"}
|
||||
)
|
||||
_MAX_MEDIA_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _capture_id(value: str) -> bool:
|
||||
return isinstance(value, str) and bool(_CAPTURE_ID.fullmatch(value))
|
||||
|
||||
|
||||
def _classification(value: Mapping[str, object]) -> str:
|
||||
if not isinstance(value, Mapping) or set(value) != _CLASSIFICATION_KEYS:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
||||
payload = dict(value)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
confidence = payload["confidence"]
|
||||
record_types = payload["record_types"]
|
||||
if isinstance(confidence, bool) or not isinstance(confidence, int | float):
|
||||
raise ValueError("classification confidence is invalid")
|
||||
if not isinstance(record_types, list) or any(
|
||||
not isinstance(record_type, str) for record_type in record_types
|
||||
):
|
||||
raise ValueError("classification record types are invalid")
|
||||
canonical_confidence = 0.0 if confidence == 0 else float(confidence)
|
||||
canonical = {
|
||||
"family": payload["family"],
|
||||
"view": payload["view"],
|
||||
"gear_slot": payload["gear_slot"],
|
||||
"record_types": sorted(record_types),
|
||||
"confidence": canonical_confidence,
|
||||
"abstention_reason": payload["abstention_reason"],
|
||||
}
|
||||
encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||
except (ContractValidationError, TypeError, ValueError) as error:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape") from error
|
||||
if not 2 <= len(encoded) <= 4096:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
||||
return encoded
|
||||
|
||||
|
||||
def _stored_classification(value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, str) or not 2 <= len(value) <= 4096:
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StorageError("stored corpus record is invalid") from error
|
||||
if not isinstance(decoded, dict) or set(decoded) != _CLASSIFICATION_KEYS:
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
try:
|
||||
validate_classification(decoded)
|
||||
except ContractValidationError as error:
|
||||
raise StorageError("stored corpus record is invalid") from error
|
||||
return decoded
|
||||
|
||||
|
||||
class CaptureCorpus:
|
||||
"""SQLite-backed implementation of the reviewed ``CaptureCorpusPort``.
|
||||
|
||||
The corpus exposes only opaque capture identifiers. It serializes writes with
|
||||
``BEGIN IMMEDIATE`` so repeated labels and rejections have deterministic
|
||||
idempotence, while differing labels preserve the replaced value as evidence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: StorageDatabase,
|
||||
blobs: ContentAddressedBlobStore,
|
||||
*,
|
||||
max_media_bytes: int = _MAX_MEDIA_BYTES,
|
||||
) -> None:
|
||||
if (
|
||||
isinstance(max_media_bytes, bool)
|
||||
or not isinstance(max_media_bytes, int)
|
||||
or not 1 <= max_media_bytes <= _MAX_MEDIA_BYTES
|
||||
):
|
||||
raise ValueError("corpus media limit is invalid")
|
||||
self.database = database
|
||||
self.blobs = blobs
|
||||
self.max_media_bytes = max_media_bytes
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 256:
|
||||
raise ValueError("capture list limit is invalid")
|
||||
with self.database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
||||
"ORDER BY c.received_at DESC, c.capture_id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
return [self._record(row) for row in rows]
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
if not _capture_id(capture_id):
|
||||
return None
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
||||
"WHERE c.capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
return None if row is None else self._record(row)
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
if not _capture_id(capture_id):
|
||||
return None
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT b.sha256, b.byte_size, b.media_type FROM captures AS c "
|
||||
"JOIN blobs AS b ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
digest, size, media_type = row["sha256"], row["byte_size"], row["media_type"]
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or isinstance(size, bool)
|
||||
or not isinstance(size, int)
|
||||
or not 1 <= size <= self.max_media_bytes
|
||||
or not isinstance(media_type, str)
|
||||
or media_type not in _MEDIA_TYPES
|
||||
):
|
||||
raise StorageError("stored corpus media is invalid")
|
||||
try:
|
||||
chunks = self.blobs.read(
|
||||
digest,
|
||||
max_bytes=self.max_media_bytes,
|
||||
expected_size=size,
|
||||
)
|
||||
except (BlobCorruptionError, ValueError) as error:
|
||||
raise StorageError("stored corpus media is unavailable") from error
|
||||
return MediaDescriptor(media_type=media_type, byte_size=size, chunks=chunks)
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
encoded = _classification(classification)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if not self._exists(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if self._has_rejection(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "conflict"
|
||||
current = connection.execute(
|
||||
"SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if current is None:
|
||||
connection.execute(
|
||||
"INSERT INTO capture_labels(capture_id, classification_json, revision, "
|
||||
"labeled_at) VALUES (?, ?, 0, ?)",
|
||||
(capture_id, encoded, _now()),
|
||||
)
|
||||
else:
|
||||
current_json = current["classification_json"]
|
||||
_stored_classification(current_json)
|
||||
if current_json == encoded:
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
revision = current["revision"]
|
||||
revision_is_valid = (
|
||||
not isinstance(revision, bool)
|
||||
and isinstance(revision, int)
|
||||
and revision >= 0
|
||||
)
|
||||
if not revision_is_valid:
|
||||
raise StorageError("stored corpus label is invalid")
|
||||
assert isinstance(revision, int) and not isinstance(revision, bool)
|
||||
connection.execute(
|
||||
"INSERT INTO capture_label_evidence(capture_id, replaced_revision, "
|
||||
"classification_json, replaced_at) VALUES (?, ?, ?, ?)",
|
||||
(capture_id, revision, current_json, _now()),
|
||||
)
|
||||
result = connection.execute(
|
||||
"UPDATE capture_labels SET classification_json = ?, "
|
||||
"revision = revision + 1, labeled_at = ? "
|
||||
"WHERE capture_id = ? AND revision = ?",
|
||||
(encoded, _now(), capture_id, revision),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if not self._exists(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if not self._has_rejection(connection, capture_id):
|
||||
connection.execute(
|
||||
"INSERT INTO capture_rejections(capture_id, rejected_at) VALUES (?, ?)",
|
||||
(capture_id, _now()),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
return self._delete_locked(capture_id)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def _delete_locked(self, capture_id: str) -> MutationOutcome:
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT blob_sha256, state, received_at, receipt_token, "
|
||||
"failure_reason, revision "
|
||||
"FROM captures WHERE capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if self._retained(connection, capture_id) or not self._deletable_capture(row):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
digest = row["blob_sha256"]
|
||||
blob = connection.execute(
|
||||
"SELECT byte_size FROM blobs WHERE sha256 = ?", (digest,)
|
||||
).fetchone()
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or blob is None
|
||||
or isinstance(blob["byte_size"], bool)
|
||||
or not isinstance(blob["byte_size"], int)
|
||||
or not 1 <= blob["byte_size"] <= self.max_media_bytes
|
||||
):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
try:
|
||||
self.blobs.verify(digest, expected_size=blob["byte_size"])
|
||||
except (BlobCorruptionError, ValueError):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
references = connection.execute(
|
||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
if not isinstance(references, int) or references < 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
if references == 1:
|
||||
connection.execute(
|
||||
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
|
||||
"capture_state, received_at, receipt_token, failure_reason, revision, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
digest,
|
||||
capture_id,
|
||||
blob["byte_size"],
|
||||
row["state"],
|
||||
row["received_at"],
|
||||
row["receipt_token"],
|
||||
row["failure_reason"],
|
||||
row["revision"],
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
result = connection.execute(
|
||||
"DELETE FROM captures WHERE capture_id = ?", (capture_id,)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
connection.execute("COMMIT")
|
||||
if references > 1:
|
||||
return "updated"
|
||||
return (
|
||||
"updated"
|
||||
if self._reconcile_locked(limit=1, restore_on_failure=True)
|
||||
else "retained"
|
||||
)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _exists(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM captures WHERE capture_id = ?", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_rejection(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM capture_rejections WHERE capture_id = ?", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retained(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
tables = (
|
||||
"jobs",
|
||||
"ocr_runs",
|
||||
"capture_labels",
|
||||
"capture_label_evidence",
|
||||
"capture_rejections",
|
||||
)
|
||||
if any(
|
||||
connection.execute(
|
||||
f"SELECT 1 FROM {table} WHERE capture_id = ? LIMIT 1", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
for table in tables
|
||||
):
|
||||
return True
|
||||
if (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM runtime_failures WHERE subject_type = 'capture' "
|
||||
"AND subject_id = ? LIMIT 1",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM lineage WHERE child_id = ? OR parent_id = ? LIMIT 1",
|
||||
(capture_id, capture_id),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _deletable_capture(row: sqlite3.Row) -> bool:
|
||||
return row["state"] in {"stored", "deduplicated"} and row["failure_reason"] is None
|
||||
|
||||
def reconcile_deletions(self, *, limit: int = 64) -> int:
|
||||
"""Finish bounded committed deletion intents after a crash or unlink failure."""
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise ValueError("deletion reconciliation limit is invalid")
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
return self._reconcile_locked(limit=limit, restore_on_failure=False)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def _reconcile_locked(self, *, limit: int, restore_on_failure: bool) -> int:
|
||||
with self.database.connect() as connection:
|
||||
intents = list(
|
||||
connection.execute(
|
||||
"SELECT blob_sha256, byte_size, capture_id, capture_state, received_at, "
|
||||
"receipt_token, failure_reason, revision FROM capture_deletion_intents "
|
||||
"ORDER BY created_at, capture_id LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
)
|
||||
completed = 0
|
||||
for intent in intents:
|
||||
digest, size = intent["blob_sha256"], intent["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
continue
|
||||
try:
|
||||
self.blobs.delete_verified(digest, expected_size=size)
|
||||
except BlobCorruptionError:
|
||||
try:
|
||||
self.blobs._path(digest).lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if restore_on_failure and self._restore_intent(intent):
|
||||
continue
|
||||
if restore_on_failure:
|
||||
raise StorageError("blob deletion could not be completed") from None
|
||||
continue
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()
|
||||
if active is not None:
|
||||
connection.execute("ROLLBACK")
|
||||
continue
|
||||
result = connection.execute(
|
||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
||||
)
|
||||
connection.execute("DELETE FROM blobs WHERE sha256 = ?", (digest,))
|
||||
connection.execute("COMMIT")
|
||||
completed += result.rowcount
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
return completed
|
||||
|
||||
def _restore_intent(self, intent: sqlite3.Row) -> bool:
|
||||
digest = intent["blob_sha256"]
|
||||
size = intent["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
return False
|
||||
try:
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
except (BlobCorruptionError, ValueError):
|
||||
return False
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
||||
"receipt_token, failure_reason, revision) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
intent["capture_id"],
|
||||
digest,
|
||||
intent["capture_state"],
|
||||
intent["received_at"],
|
||||
intent["receipt_token"],
|
||||
intent["failure_reason"],
|
||||
intent["revision"],
|
||||
),
|
||||
)
|
||||
result = connection.execute(
|
||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return result.rowcount == 1
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _record(row: sqlite3.Row) -> CaptureRecord:
|
||||
capture_id, state, received_at = (
|
||||
row["capture_id"],
|
||||
row["state"],
|
||||
row["received_at"],
|
||||
)
|
||||
if (
|
||||
not _capture_id(capture_id)
|
||||
or not isinstance(state, str)
|
||||
or not isinstance(received_at, str)
|
||||
or not 1 <= len(received_at) <= 40
|
||||
or "\r" in received_at
|
||||
or "\n" in received_at
|
||||
):
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
raw_label = row["classification_json"]
|
||||
return CaptureRecord(
|
||||
capture_id=capture_id,
|
||||
state=state,
|
||||
received_at=received_at,
|
||||
classification=None if raw_label is None else _stored_classification(raw_label),
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Private SQLite setup and immutable ordered utility migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISLNK
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
"""The local migration history cannot safely be applied."""
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise MigrationError("utility root must not be a symlink")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
||||
raise MigrationError("utility root must be a directory")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
class StorageDatabase:
|
||||
def __init__(self, root: Path, migrations_dir: Path) -> None:
|
||||
if not root.is_absolute() or not migrations_dir.is_absolute():
|
||||
raise ValueError("storage paths must be absolute")
|
||||
self.root = root
|
||||
self.migrations_dir = migrations_dir
|
||||
self.path = root / "capture.sqlite3"
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
_private_directory(self.root)
|
||||
if self.path.is_symlink():
|
||||
raise MigrationError("database file must not be a symlink")
|
||||
connection: sqlite3.Connection | None = None
|
||||
try:
|
||||
connection = sqlite3.connect(self.path, isolation_level=None, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
journal = connection.execute("PRAGMA journal_mode = WAL").fetchone()[0]
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
connection.execute("PRAGMA synchronous = FULL")
|
||||
checks = (
|
||||
connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1,
|
||||
journal == "wal",
|
||||
connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000,
|
||||
connection.execute("PRAGMA synchronous").fetchone()[0] == 2,
|
||||
)
|
||||
if not all(checks):
|
||||
raise MigrationError("SQLite connection invariants were not enabled")
|
||||
for candidate in (
|
||||
self.path,
|
||||
self.path.with_name(self.path.name + "-wal"),
|
||||
self.path.with_name(self.path.name + "-shm"),
|
||||
):
|
||||
if candidate.exists() and not candidate.is_symlink():
|
||||
candidate.chmod(0o600)
|
||||
return connection
|
||||
except Exception:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise
|
||||
|
||||
def _migrations(self) -> list[tuple[int, Path, str]]:
|
||||
found: list[tuple[int, Path, str]] = []
|
||||
versions: set[int] = set()
|
||||
for path in self.migrations_dir.glob("*.sql"):
|
||||
if len(path.name) < 5 or not path.name[:3].isdigit() or path.name[3] != "_":
|
||||
raise MigrationError("migration filename is invalid")
|
||||
version = int(path.name[:3])
|
||||
if version in versions:
|
||||
raise MigrationError("migration versions must be unique")
|
||||
versions.add(version)
|
||||
checksum = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
found.append((version, path, checksum))
|
||||
found.sort(key=lambda item: item[0])
|
||||
if not found:
|
||||
raise MigrationError("no storage migrations found")
|
||||
return found
|
||||
|
||||
def migrate(self) -> None:
|
||||
migrations = self._migrations()
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
"version INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, "
|
||||
"checksum TEXT NOT NULL, applied_at TEXT NOT NULL)"
|
||||
)
|
||||
applied = list(
|
||||
connection.execute(
|
||||
"SELECT version, filename, checksum FROM schema_migrations ORDER BY version"
|
||||
)
|
||||
)
|
||||
if len(applied) > len(migrations):
|
||||
raise MigrationError("applied migration is no longer present")
|
||||
expected_prefix = migrations[: len(applied)]
|
||||
for row, expected in zip(applied, expected_prefix, strict=True):
|
||||
version, path, checksum = expected
|
||||
if (row["version"], row["filename"], row["checksum"]) != (
|
||||
version,
|
||||
path.name,
|
||||
checksum,
|
||||
):
|
||||
raise MigrationError("migration history is not an exact ordered prefix")
|
||||
for version, path, checksum in migrations[len(applied) :]:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
applied_at = datetime.now(UTC).isoformat().replace("'", "''")
|
||||
connection.executescript(
|
||||
"BEGIN IMMEDIATE;\n"
|
||||
+ sql
|
||||
+ "\nINSERT INTO schema_migrations VALUES "
|
||||
+ f"({version}, '{path.name}', '{checksum}', '{applied_at}');"
|
||||
+ "\nCOMMIT;"
|
||||
)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
os.chmod(self.path, 0o600)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Utility-local capture receipts and bounded insert-only provenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from game_data_capture.domain.state_machines import require_transition
|
||||
from game_data_capture.storage.blobs import (
|
||||
BlobCorruptionError,
|
||||
BlobLockError,
|
||||
ContentAddressedBlobStore,
|
||||
)
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_MEDIA = re.compile(r"^(?:image/(?:png|webp|avif)|video/(?:mp4|webm|x-matroska))$")
|
||||
_SECRET = re.compile(
|
||||
r"(?i)(?:sk-[a-z0-9_-]{8,}|authorization\s*[:=]\s*bearer\s+\S+|"
|
||||
r"(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+|bearer\s+\S+|/[^\s]+)"
|
||||
)
|
||||
|
||||
|
||||
class StorageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _id(value: str) -> str:
|
||||
if not isinstance(value, str) or not _ID.fullmatch(value):
|
||||
raise ValueError("invalid local identifier")
|
||||
return value
|
||||
|
||||
|
||||
def _json(value: object, maximum: int) -> str:
|
||||
encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
||||
if len(encoded) > maximum:
|
||||
raise ValueError("provenance payload exceeds its bound")
|
||||
return encoded
|
||||
|
||||
|
||||
def sanitize_failure(code: str, message: str) -> tuple[str, str]:
|
||||
safe_code = re.sub(r"[^a-z0-9_]", "_", code.lower())[:64] or "storage_failure"
|
||||
compact = " ".join(message.split())
|
||||
compact = _SECRET.sub("[REDACTED]", compact)
|
||||
if len(compact) > 512 or (len(compact) > 160 and " " not in compact[:160]):
|
||||
compact = "operation failed [payload redacted]"
|
||||
return safe_code, compact[:512] or "operation failed"
|
||||
|
||||
|
||||
class CaptureRepository:
|
||||
def __init__(self, database: StorageDatabase, blobs: ContentAddressedBlobStore) -> None:
|
||||
self.database = database
|
||||
self.blobs = blobs
|
||||
|
||||
def receive(
|
||||
self,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
_id(capture_id)
|
||||
_id(receipt_token)
|
||||
if not _MEDIA.fullmatch(media_type):
|
||||
raise ValueError("unsupported media type")
|
||||
try:
|
||||
with self.blobs.operation_lock(), self.database.connect() as connection:
|
||||
pending = connection.execute(
|
||||
"SELECT 1 FROM capture_deletion_intents WHERE receipt_token = ?",
|
||||
(receipt_token,),
|
||||
).fetchone()
|
||||
if pending is not None:
|
||||
raise StorageError("blob deletion is pending")
|
||||
existing = connection.execute(
|
||||
"SELECT capture_id FROM captures WHERE receipt_token = ?", (receipt_token,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return str(existing["capture_id"])
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
try:
|
||||
digest, size = self.blobs.put(chunks, max_bytes=max_bytes)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
pending = connection.execute(
|
||||
"SELECT 1 FROM capture_deletion_intents WHERE blob_sha256 = ?",
|
||||
(digest,),
|
||||
).fetchone()
|
||||
if pending is not None:
|
||||
raise StorageError("blob deletion is pending")
|
||||
existing = connection.execute(
|
||||
"SELECT capture_id FROM captures WHERE receipt_token = ?",
|
||||
(receipt_token,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.execute("COMMIT")
|
||||
return str(existing["capture_id"])
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO blobs(sha256, byte_size, media_type, "
|
||||
"created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(digest, size, media_type, _now()),
|
||||
)
|
||||
duplicate = connection.execute(
|
||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
||||
"receipt_token) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
capture_id,
|
||||
digest,
|
||||
"deduplicated" if duplicate else "stored",
|
||||
_now(),
|
||||
receipt_token,
|
||||
),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return capture_id
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def transition(self, capture_id: str, target: str, expected_revision: int) -> int:
|
||||
_id(capture_id)
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state FROM captures WHERE capture_id = ? AND revision = ?",
|
||||
(capture_id, expected_revision),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise StorageError("stale or missing capture")
|
||||
require_transition("capture", str(row["state"]), target)
|
||||
result = connection.execute(
|
||||
"UPDATE captures SET state = ?, revision = revision + 1 "
|
||||
"WHERE capture_id = ? AND revision = ?",
|
||||
(target, capture_id, expected_revision),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("stale capture transition")
|
||||
return expected_revision + 1
|
||||
|
||||
def append_ocr_run(
|
||||
self, record_id: str, capture_id: str, engine_id: str, engine_version: str, payload: object
|
||||
) -> None:
|
||||
if not 0 < len(engine_id) <= 100 or not 0 < len(engine_version) <= 100:
|
||||
raise ValueError("OCR engine identity is invalid")
|
||||
self._insert(
|
||||
"ocr_runs",
|
||||
(
|
||||
record_id,
|
||||
capture_id,
|
||||
engine_id,
|
||||
engine_version,
|
||||
_now(),
|
||||
"completed",
|
||||
_json(payload, 1_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
def append_region(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert("regions", (record_id, run_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_classification(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert("classifications", (record_id, run_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_candidate(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert(
|
||||
"extraction_candidates", (record_id, run_id, _json(payload, 1_000_000), _now())
|
||||
)
|
||||
|
||||
def append_routing(self, record_id: str, candidate_id: str, payload: object) -> None:
|
||||
self._insert("routing_decisions", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_review(self, record_id: str, candidate_id: str, payload: object) -> None:
|
||||
self._insert("review_events", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_lineage(self, record_id: str, child_id: str, parent_id: str, relation: str) -> None:
|
||||
if not 0 < len(relation) <= 100:
|
||||
raise ValueError("lineage relation is invalid")
|
||||
self._insert("lineage", (record_id, child_id, parent_id, relation, _now()))
|
||||
|
||||
def append_export(self, record_id: str, state: str, manifest: object) -> None:
|
||||
if not state or len(state) > 100:
|
||||
raise ValueError("export state is invalid")
|
||||
self._insert("exports", (record_id, state, _json(manifest, 1_000_000), _now()))
|
||||
|
||||
def _insert(self, table: str, values: tuple[object, ...]) -> None:
|
||||
specs = {
|
||||
"ocr_runs": (
|
||||
"run_id,capture_id,engine_id,engine_version,created_at,state,payload_json",
|
||||
(0, 1),
|
||||
),
|
||||
"regions": ("region_id,run_id,region_json,created_at", (0, 1)),
|
||||
"classifications": ("classification_id,run_id,classification_json,created_at", (0, 1)),
|
||||
"extraction_candidates": ("candidate_id,run_id,candidate_json,created_at", (0, 1)),
|
||||
"routing_decisions": ("decision_id,candidate_id,decision_json,created_at", (0, 1)),
|
||||
"review_events": ("event_id,candidate_id,event_json,created_at", (0, 1)),
|
||||
"lineage": ("lineage_id,child_id,parent_id,relation,created_at", (0, 1, 2)),
|
||||
"exports": ("export_id,state,manifest_json,created_at", (0,)),
|
||||
}
|
||||
columns, positions = specs[table]
|
||||
if len(values) != len(columns.split(",")):
|
||||
raise ValueError("provenance record arity is invalid")
|
||||
for position in positions:
|
||||
_id(str(values[position]))
|
||||
placeholders = ", ".join("?" for _ in values)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", values)
|
||||
|
||||
def report(self) -> dict[str, int]:
|
||||
tables = ("blobs", "captures", "jobs", "ocr_runs", "runtime_failures")
|
||||
with self.database.connect() as connection:
|
||||
return {
|
||||
table: int(connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
|
||||
for table in tables
|
||||
}
|
||||
|
||||
def verify(self) -> list[str]:
|
||||
with self.database.connect() as connection:
|
||||
return [
|
||||
str(row["sha256"])
|
||||
for row in connection.execute("SELECT sha256, byte_size FROM blobs")
|
||||
if self._invalid_blob(row)
|
||||
]
|
||||
|
||||
def _invalid_blob(self, row: sqlite3.Row) -> bool:
|
||||
try:
|
||||
digest = row["sha256"]
|
||||
size = row["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
return True
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
except BlobCorruptionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
def record_failure(
|
||||
self, failure_id: str, subject_type: str, subject_id: str, code: str, message: str
|
||||
) -> None:
|
||||
if not re.fullmatch(r"[a-z_]{1,64}", subject_type):
|
||||
raise ValueError("failure subject type is invalid")
|
||||
code, message = sanitize_failure(code, message)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO runtime_failures VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(_id(failure_id), subject_type, _id(subject_id), code, message, _now()),
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""FastAPI factory for the local capture-and-labeling boundary.
|
||||
|
||||
Construction only retains injected ports and process-local session state. It does
|
||||
not bind a listener, initialize runtime paths, open SQLite, arm capture, read a
|
||||
clipboard, or create a child process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol, TypeVar
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.domain.state_machines import CaptureState
|
||||
from game_data_capture.domain.validation import ContractValidationError, validate_classification
|
||||
from game_data_capture.web.security import (
|
||||
AuthenticatedSession,
|
||||
AuthenticationError,
|
||||
BodyLimitExceeded,
|
||||
ClientDisconnected,
|
||||
LocalAuthority,
|
||||
LocalRequestLimitsMiddleware,
|
||||
RequestLimits,
|
||||
SecurityError,
|
||||
SessionStore,
|
||||
response_security_headers,
|
||||
set_session_cookie,
|
||||
single_header,
|
||||
validate_origin,
|
||||
)
|
||||
|
||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
||||
_STATE_VALUES = frozenset(state.value for state in CaptureState)
|
||||
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
||||
_OUTCOME_VALUES = frozenset({"updated", "not_found", "conflict", "retained"})
|
||||
_PortResult = TypeVar("_PortResult")
|
||||
|
||||
|
||||
class ServiceUnavailable(RuntimeError):
|
||||
"""An injected port cannot safely complete this local request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebConfig:
|
||||
"""Static local service settings supplied by runtime integration."""
|
||||
|
||||
server: ServerConfig
|
||||
request_limits: RequestLimits = field(default_factory=RequestLimits)
|
||||
max_capture_items: int = 64
|
||||
max_image_bytes: int = 64 * 1024 * 1024
|
||||
bootstrap_ttl_seconds: int = 600
|
||||
session_ttl_seconds: int = 900
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, maximum in (
|
||||
(self.max_capture_items, 256),
|
||||
(self.max_image_bytes, 64 * 1024 * 1024),
|
||||
(self.bootstrap_ttl_seconds, 3600),
|
||||
(self.session_ttl_seconds, 3600),
|
||||
):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise ValueError("web service configuration is invalid")
|
||||
|
||||
@property
|
||||
def authority(self) -> LocalAuthority:
|
||||
return LocalAuthority(self.server.host, self.server.port)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureSessionStatus:
|
||||
armed: bool
|
||||
queue_depth: int
|
||||
queue_capacity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureRecord:
|
||||
"""A safe browser projection; ports must never place paths or OCR text here."""
|
||||
|
||||
capture_id: str
|
||||
state: str
|
||||
received_at: str
|
||||
classification: Mapping[str, object] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaDescriptor:
|
||||
"""An opaque capture-media stream supplied by a corpus port, never a path."""
|
||||
|
||||
media_type: str
|
||||
byte_size: int
|
||||
chunks: Iterable[bytes]
|
||||
|
||||
|
||||
MutationOutcome = Literal["updated", "not_found", "conflict", "retained"]
|
||||
ControlOutcome = Literal["started", "already_armed", "stopped", "already_stopped", "conflict"]
|
||||
|
||||
|
||||
class CaptureControlPort(Protocol):
|
||||
"""Narrow control boundary implemented by later runtime integration."""
|
||||
|
||||
def status(self) -> CaptureSessionStatus: ...
|
||||
|
||||
def start_session(self) -> ControlOutcome: ...
|
||||
|
||||
def stop_session(self) -> ControlOutcome: ...
|
||||
|
||||
|
||||
class CaptureCorpusPort(Protocol):
|
||||
"""Narrow durable corpus boundary; routes never access SQLite or paths."""
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]: ...
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None: ...
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None: ...
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome: ...
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome: ...
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome: ...
|
||||
|
||||
|
||||
def _response(payload: Mapping[str, object], *, status_code: int = 200) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
payload,
|
||||
status_code=status_code,
|
||||
headers={"Cache-Control": "no-store", **response_security_headers()},
|
||||
)
|
||||
|
||||
|
||||
def _error(status_code: int, code: str) -> JSONResponse:
|
||||
return _response({"error": code}, status_code=status_code)
|
||||
|
||||
|
||||
def _call_port(operation: Callable[[], _PortResult]) -> _PortResult:
|
||||
"""Translate every injected-port failure without retaining its raw message."""
|
||||
|
||||
try:
|
||||
return operation()
|
||||
except Exception as error:
|
||||
raise ServiceUnavailable("local service operation failed") from error
|
||||
|
||||
|
||||
def _valid_capture_id(capture_id: str) -> bool:
|
||||
return bool(_CAPTURE_ID.fullmatch(capture_id))
|
||||
|
||||
|
||||
def _status_projection(status: CaptureSessionStatus) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(status.armed, bool)
|
||||
or isinstance(status.queue_depth, bool)
|
||||
or isinstance(status.queue_capacity, bool)
|
||||
or not isinstance(status.queue_depth, int)
|
||||
or not isinstance(status.queue_capacity, int)
|
||||
or not 0 <= status.queue_depth <= status.queue_capacity <= 256
|
||||
):
|
||||
raise ServiceUnavailable("capture control returned an invalid status")
|
||||
return {
|
||||
"armed": status.armed,
|
||||
"queue_depth": status.queue_depth,
|
||||
"queue_capacity": status.queue_capacity,
|
||||
}
|
||||
|
||||
|
||||
def _classification_projection(classification: Mapping[str, object]) -> dict[str, object]:
|
||||
# Reconstruct only the frozen contract keys before returning port data to a browser.
|
||||
if not isinstance(classification, Mapping) or set(classification) != {
|
||||
"family",
|
||||
"view",
|
||||
"gear_slot",
|
||||
"record_types",
|
||||
"confidence",
|
||||
"abstention_reason",
|
||||
}:
|
||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
||||
payload = dict(classification)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
except ContractValidationError as error:
|
||||
raise ServiceUnavailable("corpus returned an invalid classification") from error
|
||||
family = payload["family"]
|
||||
view = payload["view"]
|
||||
gear_slot = payload["gear_slot"]
|
||||
record_types = payload["record_types"]
|
||||
confidence = payload["confidence"]
|
||||
reason = payload["abstention_reason"]
|
||||
if (
|
||||
not isinstance(family, str)
|
||||
or not isinstance(view, str)
|
||||
or (gear_slot is not None and not isinstance(gear_slot, str))
|
||||
or not isinstance(record_types, list)
|
||||
or any(not isinstance(record_type, str) for record_type in record_types)
|
||||
or isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or (reason is not None and not isinstance(reason, str))
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
||||
return {
|
||||
"family": family,
|
||||
"view": view,
|
||||
"gear_slot": gear_slot,
|
||||
"record_types": record_types,
|
||||
"confidence": confidence,
|
||||
"abstention_reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _record_projection(record: CaptureRecord) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(record.capture_id, str)
|
||||
or not _valid_capture_id(record.capture_id)
|
||||
or not isinstance(record.state, str)
|
||||
or record.state not in _STATE_VALUES
|
||||
or not isinstance(record.received_at, str)
|
||||
or not 1 <= len(record.received_at) <= 40
|
||||
or any(character in record.received_at for character in "\r\n")
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned an invalid capture")
|
||||
payload: dict[str, object] = {
|
||||
"capture_id": record.capture_id,
|
||||
"state": record.state,
|
||||
"received_at": record.received_at,
|
||||
"classification": None,
|
||||
}
|
||||
if record.classification is not None:
|
||||
payload["classification"] = _classification_projection(record.classification)
|
||||
return payload
|
||||
|
||||
|
||||
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> object:
|
||||
raise ValueError(f"unsupported JSON constant {value}")
|
||||
|
||||
|
||||
async def _read_json(request: Request, *, maximum: int) -> dict[str, object]:
|
||||
content_type = single_header(request, b"content-type", required=True)
|
||||
if content_type is None:
|
||||
raise SecurityError("unsupported request content type")
|
||||
try:
|
||||
media_type = content_type.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SecurityError("unsupported request content type") from error
|
||||
if media_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise SecurityError("unsupported request content type")
|
||||
body = await request.body()
|
||||
if not 2 <= len(body) <= maximum:
|
||||
raise SecurityError("request JSON size is invalid")
|
||||
try:
|
||||
value = json.loads(
|
||||
body.decode("utf-8"),
|
||||
object_pairs_hook=_json_pairs,
|
||||
parse_constant=_reject_json_constant,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise SecurityError("request JSON is invalid") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SecurityError("request JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _bootstrap_payload(value: Mapping[str, object]) -> str:
|
||||
if set(value) != {"token"}:
|
||||
raise SecurityError("bootstrap payload is invalid")
|
||||
token = value.get("token")
|
||||
if not isinstance(token, str) or not 32 <= len(token) <= 128:
|
||||
raise SecurityError("bootstrap payload is invalid")
|
||||
return token
|
||||
|
||||
|
||||
def _confirmation_payload(value: Mapping[str, object], confirmation: str) -> None:
|
||||
if set(value) != {"confirmation"} or value.get("confirmation") != confirmation:
|
||||
raise SecurityError("confirmation payload is invalid")
|
||||
|
||||
|
||||
def _label_payload(value: Mapping[str, object]) -> dict[str, object]:
|
||||
expected = {
|
||||
"family",
|
||||
"view",
|
||||
"gear_slot",
|
||||
"record_types",
|
||||
"confidence",
|
||||
"abstention_reason",
|
||||
}
|
||||
if set(value) != expected:
|
||||
raise SecurityError("label payload is invalid")
|
||||
family = value.get("family")
|
||||
view = value.get("view")
|
||||
record_types = value.get("record_types")
|
||||
reason = value.get("abstention_reason")
|
||||
if (
|
||||
not isinstance(family, str)
|
||||
or not 1 <= len(family) <= 32
|
||||
or not isinstance(view, str)
|
||||
or not 1 <= len(view) <= 32
|
||||
or not isinstance(record_types, list)
|
||||
or not 0 <= len(record_types) <= 8
|
||||
or any(
|
||||
not isinstance(record_type, str) or not 1 <= len(record_type) <= 64
|
||||
for record_type in record_types
|
||||
)
|
||||
or (reason is not None and (not isinstance(reason, str) or not 1 <= len(reason) <= 64))
|
||||
):
|
||||
raise SecurityError("label payload is invalid")
|
||||
payload = dict(value)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
except ContractValidationError as error:
|
||||
raise SecurityError("label payload is invalid") from error
|
||||
return payload
|
||||
|
||||
|
||||
def _outcome_response(outcome: MutationOutcome) -> tuple[int, str]:
|
||||
if outcome not in _OUTCOME_VALUES:
|
||||
raise ServiceUnavailable("corpus returned an invalid mutation outcome")
|
||||
if outcome == "updated":
|
||||
return 200, "updated"
|
||||
if outcome == "not_found":
|
||||
return 404, "not_found"
|
||||
if outcome == "conflict":
|
||||
return 409, "conflict"
|
||||
return 409, "retained"
|
||||
|
||||
|
||||
def _media_bytes(media: MediaDescriptor, maximum: int) -> tuple[str, bytes]:
|
||||
"""Materialize and validate bounded port media before response headers exist."""
|
||||
|
||||
try:
|
||||
if (
|
||||
media.media_type not in _IMAGE_MEDIA_TYPES
|
||||
or isinstance(media.byte_size, bool)
|
||||
or not isinstance(media.byte_size, int)
|
||||
or not 1 <= media.byte_size <= maximum
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
materialized = bytearray()
|
||||
for chunk in media.chunks:
|
||||
if not isinstance(chunk, bytes) or not chunk:
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
if (
|
||||
len(materialized) + len(chunk) > media.byte_size
|
||||
or len(materialized) + len(chunk) > maximum
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
materialized.extend(chunk)
|
||||
if len(materialized) != media.byte_size:
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
return media.media_type, bytes(materialized)
|
||||
except ServiceUnavailable:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ServiceUnavailable("corpus media stream failed") from error
|
||||
|
||||
|
||||
def _session_response(
|
||||
payload: dict[str, object],
|
||||
session_store: SessionStore,
|
||||
session: AuthenticatedSession,
|
||||
config: WebConfig,
|
||||
) -> JSONResponse:
|
||||
rotated = session_store.rotate(session)
|
||||
payload["csrf_token"] = rotated.csrf_token
|
||||
response = _response(payload)
|
||||
set_session_cookie(response, rotated, max_age=config.session_ttl_seconds)
|
||||
return response
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
config: WebConfig,
|
||||
capture_control: CaptureControlPort,
|
||||
corpus: CaptureCorpusPort,
|
||||
bootstrap_token: str,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
) -> FastAPI:
|
||||
"""Create an unbound app using only narrow, caller-injected local ports."""
|
||||
|
||||
authority = config.authority
|
||||
sessions = SessionStore(
|
||||
bootstrap_token,
|
||||
now=now,
|
||||
bootstrap_ttl_seconds=config.bootstrap_ttl_seconds,
|
||||
session_ttl_seconds=config.session_ttl_seconds,
|
||||
)
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.add_middleware(
|
||||
LocalRequestLimitsMiddleware, authority=authority, limits=config.request_limits
|
||||
)
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(
|
||||
directory=Path(__file__).with_name("static"),
|
||||
check_dir=False,
|
||||
follow_symlink=False,
|
||||
),
|
||||
name="static",
|
||||
)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
|
||||
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error(_: Request, __: AuthenticationError) -> JSONResponse:
|
||||
return _error(401, "authentication_required")
|
||||
|
||||
@app.exception_handler(BodyLimitExceeded)
|
||||
async def body_limit_error(_: Request, __: BodyLimitExceeded) -> JSONResponse:
|
||||
return _error(413, "request_too_large")
|
||||
|
||||
@app.exception_handler(ClientDisconnected)
|
||||
async def disconnect_error(_: Request, __: ClientDisconnected) -> JSONResponse:
|
||||
return _error(400, "request_rejected")
|
||||
|
||||
@app.exception_handler(SecurityError)
|
||||
async def security_error(_: Request, __: SecurityError) -> JSONResponse:
|
||||
return _error(403, "request_forbidden")
|
||||
|
||||
@app.exception_handler(ServiceUnavailable)
|
||||
async def service_error(_: Request, __: ServiceUnavailable) -> JSONResponse:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
return _response({"status": "ok"})
|
||||
|
||||
@app.get("/", response_class=Response)
|
||||
async def review_page(request: Request) -> Response:
|
||||
return templates.TemplateResponse(request=request, name="index.html", context={})
|
||||
|
||||
@app.post("/api/bootstrap")
|
||||
async def bootstrap(request: Request) -> JSONResponse:
|
||||
validate_origin(request, authority)
|
||||
session = sessions.bootstrap(_bootstrap_payload(await _read_json(request, maximum=256)))
|
||||
response = _response({"csrf_token": session.csrf_token})
|
||||
set_session_cookie(response, session, max_age=config.session_ttl_seconds)
|
||||
return response
|
||||
|
||||
@app.get("/api/session")
|
||||
async def session_status(request: Request) -> JSONResponse:
|
||||
session = sessions.authenticate(request)
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["csrf_token"] = session.csrf_token
|
||||
return _response(payload)
|
||||
|
||||
@app.post("/api/session/start")
|
||||
async def start_session(request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "START")
|
||||
outcome = _call_port(capture_control.start_session)
|
||||
if outcome not in {"started", "already_armed", "conflict"}:
|
||||
raise ServiceUnavailable("capture control returned an invalid start outcome")
|
||||
if outcome == "conflict":
|
||||
return _error(409, "conflict")
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["result"] = outcome
|
||||
return _session_response(payload, sessions, session, config)
|
||||
|
||||
@app.post("/api/session/stop")
|
||||
async def stop_session(request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "STOP")
|
||||
outcome = _call_port(capture_control.stop_session)
|
||||
if outcome not in {"stopped", "already_stopped", "conflict"}:
|
||||
raise ServiceUnavailable("capture control returned an invalid stop outcome")
|
||||
if outcome == "conflict":
|
||||
return _error(409, "conflict")
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["result"] = outcome
|
||||
return _session_response(payload, sessions, session, config)
|
||||
|
||||
@app.get("/api/captures")
|
||||
async def list_captures(request: Request) -> JSONResponse:
|
||||
sessions.authenticate(request)
|
||||
|
||||
def list_projection() -> list[dict[str, object]]:
|
||||
records = corpus.list_captures(limit=config.max_capture_items)
|
||||
if len(records) > config.max_capture_items:
|
||||
raise ValueError("corpus returned too many captures")
|
||||
return [_record_projection(record) for record in records]
|
||||
|
||||
return _response({"captures": _call_port(list_projection)})
|
||||
|
||||
@app.get("/api/captures/{capture_id}")
|
||||
async def get_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
sessions.authenticate(request)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
record = _call_port(lambda: corpus.get_capture(capture_id))
|
||||
if record is None:
|
||||
return _error(404, "not_found")
|
||||
return _response({"capture": _call_port(lambda: _record_projection(record))})
|
||||
|
||||
@app.get("/api/captures/{capture_id}/media")
|
||||
async def capture_media(capture_id: str, request: Request) -> Response:
|
||||
sessions.authenticate(request)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
media = _call_port(lambda: corpus.open_media(capture_id))
|
||||
if media is None:
|
||||
return _error(404, "not_found")
|
||||
media_type, content = _call_port(lambda: _media_bytes(media, config.max_image_bytes))
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/label")
|
||||
async def label_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
classification = _label_payload(await _read_json(request, maximum=4096))
|
||||
outcome = _call_port(lambda: corpus.label(capture_id, classification))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/reject")
|
||||
async def reject_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "REJECT")
|
||||
outcome = _call_port(lambda: corpus.reject_preserving_evidence(capture_id))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/delete")
|
||||
async def delete_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "DELETE")
|
||||
outcome = _call_port(lambda: corpus.delete_with_retention(capture_id))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Local-only request guards and process-local session state.
|
||||
|
||||
This module intentionally has no runtime, storage, filesystem, or server imports.
|
||||
The runtime integration layer owns listener construction and must disable Uvicorn
|
||||
proxy headers so forwarded request headers never affect this boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
_SECRET = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
||||
_COOKIE_NAME = "capture_session"
|
||||
_RESPONSE_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
|
||||
(
|
||||
b"content-security-policy",
|
||||
b"default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; "
|
||||
b"frame-ancestors 'none'; "
|
||||
b"script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; "
|
||||
b"media-src 'self'; connect-src 'self'; form-action 'self'",
|
||||
),
|
||||
(b"referrer-policy", b"no-referrer"),
|
||||
(b"x-content-type-options", b"nosniff"),
|
||||
(b"x-frame-options", b"DENY"),
|
||||
)
|
||||
|
||||
|
||||
def response_security_headers() -> dict[str, str]:
|
||||
"""Return response headers for errors generated outside the ASGI wrapper."""
|
||||
|
||||
return {
|
||||
name.decode("ascii"): value.decode("ascii") for name, value in _RESPONSE_SECURITY_HEADERS
|
||||
}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
"""A request failed a security boundary without exposing its input."""
|
||||
|
||||
|
||||
class AuthenticationError(SecurityError):
|
||||
"""The process-local authenticated session is absent or expired."""
|
||||
|
||||
|
||||
class BodyLimitExceeded(SecurityError):
|
||||
"""The declared or streamed request body exceeded the configured limit."""
|
||||
|
||||
|
||||
class ClientDisconnected(SecurityError):
|
||||
"""The client disconnected before a route obtained a complete request body."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalAuthority:
|
||||
"""The single exact loopback authority accepted by the HTTP boundary."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
address = ipaddress.ip_address(self.host)
|
||||
except ValueError as error:
|
||||
raise ValueError("web host must be a loopback IP literal") from error
|
||||
if not address.is_loopback or not 1024 <= self.port <= 65535:
|
||||
raise ValueError("web authority is invalid")
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def origin(self) -> str:
|
||||
return f"http://{self.authority}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RequestLimits:
|
||||
max_header_bytes: int = 16 * 1024
|
||||
max_headers: int = 64
|
||||
max_body_bytes: int = 16 * 1024
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value in (self.max_header_bytes, self.max_headers, self.max_body_bytes):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError("request limits must be positive integers")
|
||||
|
||||
|
||||
def new_bootstrap_token() -> str:
|
||||
"""Create a fragment-safe, one-time token for the local bootstrap mutation."""
|
||||
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _header_values(scope: Scope, name: bytes) -> list[bytes]:
|
||||
return [value for key, value in scope.get("headers", []) if key.lower() == name]
|
||||
|
||||
|
||||
def _single_header(scope: Scope, name: bytes, *, required: bool) -> bytes | None:
|
||||
values = _header_values(scope, name)
|
||||
if len(values) != 1:
|
||||
if not values and not required:
|
||||
return None
|
||||
raise SecurityError("ambiguous request header")
|
||||
return values[0]
|
||||
|
||||
|
||||
def single_header(request: Request, name: bytes, *, required: bool) -> bytes | None:
|
||||
"""Return one unambiguous request header without trusting framework merging."""
|
||||
|
||||
return _single_header(request.scope, name, required=required)
|
||||
|
||||
|
||||
def _ascii(value: bytes) -> str:
|
||||
try:
|
||||
return value.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SecurityError("invalid request header") from error
|
||||
|
||||
|
||||
def validate_host(scope: Scope, authority: LocalAuthority) -> None:
|
||||
value = _single_header(scope, b"host", required=True)
|
||||
assert value is not None
|
||||
if not secrets.compare_digest(_ascii(value), authority.authority):
|
||||
raise SecurityError("unexpected request authority")
|
||||
|
||||
|
||||
def validate_origin(request: Request, authority: LocalAuthority) -> None:
|
||||
value = _single_header(request.scope, b"origin", required=True)
|
||||
assert value is not None
|
||||
if not secrets.compare_digest(_ascii(value), authority.origin):
|
||||
raise SecurityError("unexpected request origin")
|
||||
|
||||
|
||||
def _validate_content_length(scope: Scope, maximum: int) -> None:
|
||||
value = _single_header(scope, b"content-length", required=False)
|
||||
if value is None:
|
||||
return
|
||||
raw = _ascii(value)
|
||||
if not raw.isascii() or not raw.isdecimal():
|
||||
raise SecurityError("invalid content length")
|
||||
if int(raw) > maximum:
|
||||
raise BodyLimitExceeded("request body is too large")
|
||||
|
||||
|
||||
async def _send_rejection(send: Send, status: int, code: str) -> None:
|
||||
body = json.dumps({"error": code}, separators=(",", ":")).encode("ascii")
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(body)).encode("ascii")),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
class LocalRequestLimitsMiddleware:
|
||||
"""Validate headers and cap all ASGI body chunks before application parsing."""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, authority: LocalAuthority, limits: RequestLimits) -> None:
|
||||
self.app = app
|
||||
self.authority = authority
|
||||
self.limits = limits
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def secured_send(message: Message) -> None:
|
||||
if message["type"] != "http.response.start":
|
||||
await send(message)
|
||||
return
|
||||
headers = list(message.get("headers", []))
|
||||
present = {name.lower() for name, _ in headers}
|
||||
headers.extend(
|
||||
(name, value) for name, value in _RESPONSE_SECURITY_HEADERS if name not in present
|
||||
)
|
||||
protected = dict(message)
|
||||
protected["headers"] = headers
|
||||
await send(protected)
|
||||
|
||||
headers = scope.get("headers", [])
|
||||
if len(headers) > self.limits.max_headers or any(
|
||||
len(name) + len(value) > self.limits.max_header_bytes for name, value in headers
|
||||
):
|
||||
await _send_rejection(secured_send, 431, "request_rejected")
|
||||
return
|
||||
if sum(len(name) + len(value) for name, value in headers) > self.limits.max_header_bytes:
|
||||
await _send_rejection(secured_send, 431, "request_rejected")
|
||||
return
|
||||
try:
|
||||
validate_host(scope, self.authority)
|
||||
_validate_content_length(scope, self.limits.max_body_bytes)
|
||||
except BodyLimitExceeded:
|
||||
await _send_rejection(secured_send, 413, "request_too_large")
|
||||
return
|
||||
except SecurityError:
|
||||
await _send_rejection(secured_send, 400, "request_rejected")
|
||||
return
|
||||
|
||||
received = 0
|
||||
request_complete = False
|
||||
|
||||
async def limited_receive() -> Message:
|
||||
nonlocal received, request_complete
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
if not request_complete:
|
||||
raise ClientDisconnected("client disconnected")
|
||||
return message
|
||||
if message["type"] != "http.request":
|
||||
return message
|
||||
body = message.get("body", b"")
|
||||
if not isinstance(body, bytes):
|
||||
raise SecurityError("invalid request body")
|
||||
received += len(body)
|
||||
if received > self.limits.max_body_bytes:
|
||||
raise BodyLimitExceeded("request body is too large")
|
||||
if not message.get("more_body", False):
|
||||
request_complete = True
|
||||
return message
|
||||
|
||||
await self.app(scope, limited_receive, secured_send)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedSession:
|
||||
token: str
|
||||
csrf_token: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""An intentionally non-durable session store invalidated on process restart."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bootstrap_token: str,
|
||||
*,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
bootstrap_ttl_seconds: int = 600,
|
||||
session_ttl_seconds: int = 900,
|
||||
) -> None:
|
||||
if not isinstance(bootstrap_token, str) or not _SECRET.fullmatch(bootstrap_token):
|
||||
raise ValueError("bootstrap token is invalid")
|
||||
if (
|
||||
isinstance(bootstrap_ttl_seconds, bool)
|
||||
or not isinstance(bootstrap_ttl_seconds, int)
|
||||
or isinstance(session_ttl_seconds, bool)
|
||||
or not isinstance(session_ttl_seconds, int)
|
||||
or not 1 <= bootstrap_ttl_seconds <= 3600
|
||||
or not 1 <= session_ttl_seconds <= 3600
|
||||
):
|
||||
raise ValueError("session lifetime is invalid")
|
||||
self._bootstrap_token = bootstrap_token.encode("ascii")
|
||||
self._bootstrap_expires_at = now() + bootstrap_ttl_seconds
|
||||
self._bootstrap_used = False
|
||||
self._now = now
|
||||
self._session_ttl_seconds = session_ttl_seconds
|
||||
self._sessions: dict[str, AuthenticatedSession] = {}
|
||||
|
||||
def bootstrap(self, submitted: str) -> AuthenticatedSession:
|
||||
if (
|
||||
self._bootstrap_used
|
||||
or self._now() > self._bootstrap_expires_at
|
||||
or not _SECRET.fullmatch(submitted)
|
||||
or not secrets.compare_digest(submitted.encode("ascii"), self._bootstrap_token)
|
||||
):
|
||||
raise AuthenticationError("bootstrap authentication failed")
|
||||
self._bootstrap_used = True
|
||||
return self._new_session()
|
||||
|
||||
def authenticate(self, request: Request) -> AuthenticatedSession:
|
||||
token = _session_cookie(request)
|
||||
session = self._sessions.get(token)
|
||||
if session is None or self._now() > session.expires_at:
|
||||
self._sessions.pop(token, None)
|
||||
raise AuthenticationError("session authentication failed")
|
||||
return session
|
||||
|
||||
def require_mutation(self, request: Request, authority: LocalAuthority) -> AuthenticatedSession:
|
||||
session = self.authenticate(request)
|
||||
validate_origin(request, authority)
|
||||
csrf = _single_header(request.scope, b"x-csrf-token", required=True)
|
||||
assert csrf is not None
|
||||
submitted = _ascii(csrf)
|
||||
if not _SECRET.fullmatch(submitted) or not secrets.compare_digest(
|
||||
submitted.encode("ascii"), session.csrf_token.encode("ascii")
|
||||
):
|
||||
raise SecurityError("csrf validation failed")
|
||||
return session
|
||||
|
||||
def rotate(self, session: AuthenticatedSession) -> AuthenticatedSession:
|
||||
current = self._sessions.get(session.token)
|
||||
if current != session or self._now() > session.expires_at:
|
||||
self._sessions.pop(session.token, None)
|
||||
raise AuthenticationError("session authentication failed")
|
||||
del self._sessions[session.token]
|
||||
return self._new_session()
|
||||
|
||||
def _new_session(self) -> AuthenticatedSession:
|
||||
session = AuthenticatedSession(
|
||||
token=secrets.token_urlsafe(32),
|
||||
csrf_token=secrets.token_urlsafe(32),
|
||||
expires_at=self._now() + self._session_ttl_seconds,
|
||||
)
|
||||
self._sessions[session.token] = session
|
||||
return session
|
||||
|
||||
|
||||
def _session_cookie(request: Request) -> str:
|
||||
raw = _single_header(request.scope, b"cookie", required=True)
|
||||
assert raw is not None
|
||||
pairs: dict[str, str] = {}
|
||||
for segment in _ascii(raw).split(";"):
|
||||
name, separator, value = segment.strip().partition("=")
|
||||
if not separator or not name or name in pairs:
|
||||
raise AuthenticationError("session authentication failed")
|
||||
pairs[name] = value
|
||||
token = pairs.get(_COOKIE_NAME)
|
||||
if token is None or not _SECRET.fullmatch(token):
|
||||
raise AuthenticationError("session authentication failed")
|
||||
return token
|
||||
|
||||
|
||||
def set_session_cookie(response: Any, session: AuthenticatedSession, *, max_age: int) -> None:
|
||||
"""Set a host-only HTTP-only local session cookie without a Domain attribute."""
|
||||
|
||||
response.set_cookie(
|
||||
key=_COOKIE_NAME,
|
||||
value=session.token,
|
||||
max_age=max_age,
|
||||
path="/",
|
||||
secure=False,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
:root {
|
||||
--canvas: #0a0c0d;
|
||||
--surface: #111415;
|
||||
--surface-raised: #141718;
|
||||
--well: #0d0f10;
|
||||
--line: #303536;
|
||||
--line-strong: #50595b;
|
||||
--text: #ecebe5;
|
||||
--muted: #a8afb1;
|
||||
--dim: #7e8789;
|
||||
--orange: #f6a821;
|
||||
--orange-hover: #ffc45d;
|
||||
--success: #8bd2a0;
|
||||
--danger: #f09a86;
|
||||
--info: #8cc8df;
|
||||
--focus: #ffc45d;
|
||||
--space: 1rem;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); }
|
||||
|
||||
button, input { font: inherit; }
|
||||
|
||||
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
|
||||
|
||||
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
|
||||
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
|
||||
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
|
||||
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
|
||||
.button-danger { color: var(--danger); }
|
||||
.quiet-danger { border-color: #6f4941; }
|
||||
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
||||
|
||||
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
|
||||
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
|
||||
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
|
||||
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
|
||||
h2 { margin-bottom: 0; font-size: 1.15rem; }
|
||||
.header-state { text-align: right; }
|
||||
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
|
||||
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
|
||||
|
||||
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
|
||||
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
|
||||
.notice[data-kind="success"] { border-left-color: var(--success); }
|
||||
.notice[data-kind="success"] strong { color: var(--success); }
|
||||
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
|
||||
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
|
||||
.notice[data-kind="retained"] { border-left-color: var(--info); }
|
||||
.notice[data-kind="retained"] strong { color: var(--info); }
|
||||
|
||||
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
|
||||
.control-strip p:last-child { margin: 0; }
|
||||
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
|
||||
.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; }
|
||||
.capture-panel, .review-panel { padding: 1rem; }
|
||||
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
|
||||
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
|
||||
.capture-list { margin: 0; padding: 0; list-style: none; }
|
||||
.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
|
||||
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
|
||||
.capture-list .capture-id { overflow-wrap: anywhere; }
|
||||
.capture-list .capture-meta { color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.review-content { padding-top: 1rem; }
|
||||
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
|
||||
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
|
||||
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
|
||||
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
|
||||
fieldset:disabled { opacity: .65; }
|
||||
legend { padding: 0 .35rem; font-weight: 700; }
|
||||
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--text); }
|
||||
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
.choice span { padding: .65rem .75rem; }
|
||||
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
|
||||
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
|
||||
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
|
||||
|
||||
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / 72%); }
|
||||
dialog p { line-height: 1.5; }
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
|
||||
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
|
||||
.header-state { text-align: left; }
|
||||
.workspace-grid { grid-template-columns: 1fr; }
|
||||
.capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
|
||||
.capture-list button { margin-top: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 33rem) {
|
||||
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
|
||||
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
|
||||
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
|
||||
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
const taxonomy = Object.freeze({
|
||||
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
|
||||
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
|
||||
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
|
||||
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
|
||||
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
|
||||
unknown: { views: ["unknown"], records: { unknown: [] } }
|
||||
});
|
||||
const families = Object.keys(taxonomy);
|
||||
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
|
||||
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
|
||||
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = Object.freeze({
|
||||
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
|
||||
});
|
||||
|
||||
let csrfToken = null;
|
||||
let activeCapture = null;
|
||||
let captures = [];
|
||||
let armed = null;
|
||||
let pending = false;
|
||||
let returnFocus = null;
|
||||
|
||||
function setText(element, value) { element.textContent = value; }
|
||||
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
||||
function label(value) { return value.replaceAll("_", " "); }
|
||||
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
|
||||
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
|
||||
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
|
||||
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
|
||||
function currentFamily() { return selected("family"); }
|
||||
function currentView() { return selected("view"); }
|
||||
|
||||
function syncControls() {
|
||||
const locked = !csrfToken;
|
||||
els.start.disabled = pending || locked || armed === true;
|
||||
els.stop.disabled = pending || locked || armed !== true;
|
||||
els.refresh.disabled = pending || locked;
|
||||
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
|
||||
els.reject.disabled = pending || locked || !activeCapture;
|
||||
els.remove.disabled = pending || locked || !activeCapture;
|
||||
document.querySelectorAll("#capture-list button").forEach((button) => {
|
||||
button.disabled = pending || locked;
|
||||
});
|
||||
document.querySelectorAll("#label-form input").forEach((input) => {
|
||||
const fieldset = input.closest("fieldset");
|
||||
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
|
||||
});
|
||||
els.labelForm.setAttribute("aria-busy", String(pending));
|
||||
}
|
||||
function setBusy(value) { pending = value; syncControls(); }
|
||||
function clearActiveDetail() {
|
||||
activeCapture = null;
|
||||
els.media.removeAttribute("src");
|
||||
els.reviewContent.hidden = true;
|
||||
els.reviewEmpty.hidden = false;
|
||||
els.reviewTitle.textContent = "No capture selected";
|
||||
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
|
||||
renderLabelForm(null);
|
||||
}
|
||||
function lock(message) {
|
||||
csrfToken = null;
|
||||
armed = null;
|
||||
captures = [];
|
||||
clearActiveDetail();
|
||||
els.sessionState.textContent = "LOCKED";
|
||||
els.captureState.textContent = "DISARMED";
|
||||
els.queueState.textContent = "Queue unavailable";
|
||||
els.listEmpty.hidden = false;
|
||||
setText(els.listEmpty, message);
|
||||
clearNode(els.list);
|
||||
els.count.textContent = "0 items";
|
||||
setBusy(false);
|
||||
}
|
||||
function validSession(payload) {
|
||||
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
|
||||
}
|
||||
function updateSession(payload) {
|
||||
armed = payload.armed;
|
||||
els.sessionState.textContent = "UNLOCKED";
|
||||
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
|
||||
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
|
||||
if (!activeCapture) els.reviewState.textContent = "READY";
|
||||
syncControls();
|
||||
}
|
||||
function safeJson(response) { return response.json().catch(() => null); }
|
||||
async function request(path, options = {}, mutation = false) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
|
||||
let response;
|
||||
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
|
||||
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
|
||||
const payload = await safeJson(response);
|
||||
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
|
||||
if (!response.ok) {
|
||||
const retained = response.status === 409 && payload.error === "retained";
|
||||
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function acceptMutationCsrf(payload) {
|
||||
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
|
||||
lock("The local service returned an invalid session update. Open the provided local address again.");
|
||||
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
|
||||
return false;
|
||||
}
|
||||
function addChoice(container, type, name, value, checked, onChange) {
|
||||
const wrapper = document.createElement("label"); wrapper.className = "choice";
|
||||
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
|
||||
const text = document.createElement("span"); text.textContent = label(value);
|
||||
wrapper.append(input, text); container.append(wrapper);
|
||||
}
|
||||
function setChecked(name, value) {
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
|
||||
}
|
||||
function setCheckedMembers(name, values) {
|
||||
const selectedValues = new Set(values);
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
|
||||
input.checked = selectedValues.has(input.value);
|
||||
});
|
||||
}
|
||||
function renderFamilies() {
|
||||
clearNode(els.family);
|
||||
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
|
||||
}
|
||||
function renderViews() {
|
||||
const family = currentFamily();
|
||||
clearNode(els.view);
|
||||
els.viewFieldset.disabled = !family;
|
||||
if (!family) return;
|
||||
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
|
||||
}
|
||||
function renderDependentFields() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
|
||||
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
|
||||
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
|
||||
els.recordFieldset.disabled = !family || !view || unknown;
|
||||
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
|
||||
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
|
||||
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
|
||||
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
|
||||
validateLabel();
|
||||
}
|
||||
function labelPayload() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
|
||||
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
|
||||
}
|
||||
function isValidLabel() {
|
||||
const payload = labelPayload();
|
||||
if (!payload.family || !payload.view) return false;
|
||||
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
|
||||
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
|
||||
}
|
||||
function validateLabel() {
|
||||
const payload = labelPayload(); let message = "";
|
||||
if (!payload.family) message = "Select the evidence family.";
|
||||
else if (!payload.view) message = "Select the screen view.";
|
||||
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
|
||||
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
|
||||
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
|
||||
setText(els.validation, message);
|
||||
if (!pending) syncControls();
|
||||
return !message;
|
||||
}
|
||||
function renderLabelForm(classification) {
|
||||
renderFamilies();
|
||||
if (!classification) { renderViews(); renderDependentFields(); return; }
|
||||
setChecked("family", classification.family);
|
||||
renderViews();
|
||||
setChecked("view", classification.view);
|
||||
renderDependentFields();
|
||||
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
|
||||
setCheckedMembers("record_type", classification.record_types || []);
|
||||
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
|
||||
validateLabel();
|
||||
}
|
||||
function renderCaptures() {
|
||||
clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0;
|
||||
if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; }
|
||||
captures.forEach((capture) => {
|
||||
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id));
|
||||
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id;
|
||||
const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`;
|
||||
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
|
||||
});
|
||||
syncControls();
|
||||
}
|
||||
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
|
||||
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
|
||||
if (!csrfToken || pending) return false;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
|
||||
const payload = await request("/api/captures");
|
||||
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
|
||||
captures = payload.captures;
|
||||
if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail();
|
||||
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
|
||||
}
|
||||
async function selectCapture(captureId, { announce = true } = {}) {
|
||||
if (!csrfToken || pending) return;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
|
||||
if (!payload?.capture) { setBusy(false); return; }
|
||||
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
|
||||
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
|
||||
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
|
||||
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
|
||||
}
|
||||
function routeForAction(action) {
|
||||
if (action === "start") return ["/api/session/start", "START"];
|
||||
if (action === "stop") return ["/api/session/stop", "STOP"];
|
||||
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
|
||||
return null;
|
||||
}
|
||||
function showConfirmation(action, source) {
|
||||
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
|
||||
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
|
||||
const message = messages[action]; if (!message) return;
|
||||
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
|
||||
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
|
||||
els.dialog.showModal(); els.dialogSubmit.focus();
|
||||
}
|
||||
async function confirmed(action, fallbackFocus) {
|
||||
if (pending) return;
|
||||
const route = routeForAction(action);
|
||||
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
|
||||
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
|
||||
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
|
||||
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
|
||||
if (action === "delete") clearActiveDetail();
|
||||
if (action === "start" || action === "stop") updateSession(payload);
|
||||
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
|
||||
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
|
||||
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
|
||||
focusIfAvailable(successFocus);
|
||||
}
|
||||
async function initialize() {
|
||||
renderLabelForm(null);
|
||||
let fragment = window.location.hash; let token = null;
|
||||
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
|
||||
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
fragment = "";
|
||||
if (token) {
|
||||
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
|
||||
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
|
||||
csrfToken = bootstrap.csrf_token;
|
||||
}
|
||||
const session = await request("/api/session");
|
||||
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
|
||||
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
|
||||
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
|
||||
notice("success", "LOCAL SESSION READY", startupCopy);
|
||||
}
|
||||
|
||||
els.start.addEventListener("click", () => showConfirmation("start", els.start));
|
||||
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
|
||||
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
|
||||
els.labelForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeCapture || !validateLabel() || pending) return;
|
||||
const captureId = activeCapture.capture_id;
|
||||
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
|
||||
setBusy(false);
|
||||
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
|
||||
if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false });
|
||||
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
|
||||
focusIfAvailable(els.save);
|
||||
});
|
||||
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
|
||||
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
|
||||
els.dialog.addEventListener("close", () => {
|
||||
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
|
||||
els.dialog.dataset.action = "";
|
||||
const focusTarget = returnFocus;
|
||||
if (focusTarget) focusTarget.focus();
|
||||
returnFocus = null;
|
||||
if (shouldConfirm && action) confirmed(action, focusTarget);
|
||||
});
|
||||
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
|
||||
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
|
||||
initialize();
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Local Capture Review</title>
|
||||
<link rel="stylesheet" href="/static/review.css" />
|
||||
<script type="module" src="/static/review.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#workspace">Skip to review workspace</a>
|
||||
<main class="shell" id="workspace">
|
||||
<header class="command-header">
|
||||
<div>
|
||||
<p class="kicker">LOCAL OPERATIONS / CAPTURE REVIEW</p>
|
||||
<h1>Evidence intake</h1>
|
||||
</div>
|
||||
<div class="header-state" aria-label="Session status">
|
||||
<strong id="session-state">LOCKED</strong>
|
||||
<span id="queue-state">Queue unavailable</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="notice" id="notice" aria-live="polite" aria-atomic="true">
|
||||
<strong id="notice-title">STARTUP</strong>
|
||||
<span id="notice-copy">Checking this local session.</span>
|
||||
</section>
|
||||
|
||||
<section class="control-strip" aria-label="Capture controls">
|
||||
<div>
|
||||
<p class="kicker">CAPTURE SESSION</p>
|
||||
<p id="capture-state">DISARMED</p>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
|
||||
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
|
||||
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-grid" aria-label="Capture review workspace">
|
||||
<aside class="capture-panel" aria-labelledby="capture-list-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="kicker">PRIVATE QUEUE</p>
|
||||
<h2 id="capture-list-title">Captures</h2>
|
||||
</div>
|
||||
<span id="capture-count">0 items</span>
|
||||
</div>
|
||||
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
|
||||
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
|
||||
</aside>
|
||||
|
||||
<section class="review-panel" aria-labelledby="review-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="kicker">MANUAL REVIEW</p>
|
||||
<h2 id="review-title">No capture selected</h2>
|
||||
</div>
|
||||
<span id="review-state">LOCKED</span>
|
||||
</div>
|
||||
|
||||
<div class="review-empty" id="review-empty">
|
||||
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
|
||||
</div>
|
||||
|
||||
<div class="review-content" id="review-content" hidden>
|
||||
<figure class="evidence-frame">
|
||||
<img id="capture-media" alt="Private captured game screen" />
|
||||
<figcaption id="capture-meta"></figcaption>
|
||||
</figure>
|
||||
|
||||
<form id="label-form" novalidate>
|
||||
<fieldset id="family-fieldset">
|
||||
<legend>Evidence family</legend>
|
||||
<div class="choice-grid" id="family-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="view-fieldset" disabled>
|
||||
<legend>Screen view</legend>
|
||||
<div class="choice-grid" id="view-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="gear-slot-fieldset" hidden disabled>
|
||||
<legend>Gear slot</legend>
|
||||
<div class="choice-grid" id="gear-slot-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="record-types-fieldset" disabled>
|
||||
<legend>Canonical records evidenced</legend>
|
||||
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
|
||||
<div class="choice-grid" id="record-type-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="abstention-fieldset" hidden disabled>
|
||||
<legend>Unknown screen reason</legend>
|
||||
<div class="choice-grid" id="abstention-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<p class="validation-message" id="label-validation" role="status"></p>
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
|
||||
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
|
||||
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
|
||||
</div>
|
||||
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
|
||||
<form method="dialog" id="confirmation-form">
|
||||
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
|
||||
<h2 id="confirmation-title">Confirm action</h2>
|
||||
<p id="confirmation-copy"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
|
||||
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user