from __future__ import annotations
|
|
from dataclasses import dataclass, field
|
from enum import Enum
|
from pathlib import Path
|
from typing import Any, Mapping
|
import re
|
|
|
class ContractError(ValueError):
|
def __init__(self, code: "ErrorCode", field_name: str | None, detail: str):
|
super().__init__(f"{code.value}:{field_name or '-'}:{detail}")
|
self.code = code
|
self.field_name = field_name
|
self.detail = detail
|
|
|
class ErrorCode(str, Enum):
|
NONE = "NONE"
|
TASK_SPEC_INVALID = "TASK_SPEC_INVALID"
|
CLOCK_INVALID = "CLOCK_INVALID"
|
DEADLINE_EXPIRED = "DEADLINE_EXPIRED"
|
PROCESS_START_FAILED = "PROCESS_START_FAILED"
|
PROCESS_TIMEOUT = "PROCESS_TIMEOUT"
|
PROCESS_OUTPUT_LIMIT = "PROCESS_OUTPUT_LIMIT"
|
PROCESS_LIVENESS_UNKNOWN = "PROCESS_LIVENESS_UNKNOWN"
|
DEVICE_NOT_UNIQUE = "DEVICE_NOT_UNIQUE"
|
DEVICE_NOT_ONLINE = "DEVICE_NOT_ONLINE"
|
PACKAGE_MISSING = "PACKAGE_MISSING"
|
CACHE_UNREADABLE = "CACHE_UNREADABLE"
|
UI_ANCHOR_DRIFT = "UI_ANCHOR_DRIFT"
|
ACCESS_CONTROL_PRESENT = "ACCESS_CONTROL_PRESENT"
|
UI_CURSOR_RESTORE_FAILED = "UI_CURSOR_RESTORE_FAILED"
|
DETAIL_RESULT_MISMATCH = "DETAIL_RESULT_MISMATCH"
|
CANDIDATE_REJECTED = "CANDIDATE_REJECTED"
|
TRIGGER_NOT_OCCURRED = "TRIGGER_NOT_OCCURRED"
|
TRIGGER_UNCERTAIN = "TRIGGER_UNCERTAIN"
|
CACHE_TIMEOUT = "CACHE_TIMEOUT"
|
CACHE_AMBIGUOUS = "CACHE_AMBIGUOUS"
|
REMOTE_FILE_CHANGED = "REMOTE_FILE_CHANGED"
|
LOCK_TIMEOUT = "LOCK_TIMEOUT"
|
QUOTA_LEDGER_INVALID = "QUOTA_LEDGER_INVALID"
|
QUOTA_REPLAY_CONFLICT = "QUOTA_REPLAY_CONFLICT"
|
QUOTA_EXHAUSTED = "QUOTA_EXHAUSTED"
|
QUOTA_DATE_UNCERTAIN = "QUOTA_DATE_UNCERTAIN"
|
PULL_FAILED = "PULL_FAILED"
|
PDF_MAGIC_INVALID = "PDF_MAGIC_INVALID"
|
BYTE_COUNT_MISMATCH = "BYTE_COUNT_MISMATCH"
|
HASH_MISMATCH = "HASH_MISMATCH"
|
PDF_NOT_OPENABLE = "PDF_NOT_OPENABLE"
|
PAGE_COUNT_MISMATCH = "PAGE_COUNT_MISMATCH"
|
PDF_ENCRYPTED = "PDF_ENCRYPTED"
|
FINAL_PATH_CONFLICT = "FINAL_PATH_CONFLICT"
|
PUBLISH_FAILED = "PUBLISH_FAILED"
|
RECOVERY_UNKNOWN = "RECOVERY_UNKNOWN"
|
MANIFEST_INVALID = "MANIFEST_INVALID"
|
PERSIST_LATE = "PERSIST_LATE"
|
DUPLICATE_EXISTING_ARTIFACT = "DUPLICATE_EXISTING_ARTIFACT"
|
UNEXPECTED_EXCEPTION = "UNEXPECTED_EXCEPTION"
|
|
|
class TerminalStatus(str, Enum):
|
STATE_UNCERTAIN = "STATE_UNCERTAIN"
|
BLOCKED_ACCESS_CONTROL = "BLOCKED_ACCESS_CONTROL"
|
TIME_BUDGET_STOP = "TIME_BUDGET_STOP"
|
PARTIAL_QUOTA_STOP = "PARTIAL_QUOTA_STOP"
|
BLOCKED_INPUT = "BLOCKED_INPUT"
|
BLOCKED_ENVIRONMENT = "BLOCKED_ENVIRONMENT"
|
BLOCKED_AMBIGUOUS_MAPPING = "BLOCKED_AMBIGUOUS_MAPPING"
|
VALIDATION_FAILED = "VALIDATION_FAILED"
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
|
SUCCESS = "SUCCESS"
|
|
|
TERMINAL_MAPPING: dict[TerminalStatus, tuple[str, int]] = {
|
TerminalStatus.STATE_UNCERTAIN: ("STATE_UNCERTAIN", 27),
|
TerminalStatus.BLOCKED_ACCESS_CONTROL: ("BLOCKED_ACCESS_CONTROL", 13),
|
TerminalStatus.TIME_BUDGET_STOP: ("TIME_BUDGET_STOP", 10),
|
TerminalStatus.PARTIAL_QUOTA_STOP: ("PARTIAL_QUOTA_STOP", 11),
|
TerminalStatus.BLOCKED_INPUT: ("BLOCKED_INPUT", 12),
|
TerminalStatus.BLOCKED_ENVIRONMENT: ("BLOCKED_ENVIRONMENT", 14),
|
TerminalStatus.BLOCKED_AMBIGUOUS_MAPPING: ("BLOCKED_AMBIGUOUS_MAPPING", 15),
|
TerminalStatus.VALIDATION_FAILED: ("VALIDATION_FAILED", 16),
|
TerminalStatus.INTERNAL_ERROR: ("INTERNAL_ERROR", 20),
|
TerminalStatus.PARTIAL_SUCCESS: ("ACCEPTED", 2),
|
TerminalStatus.SUCCESS: ("ACCEPTED", 0),
|
}
|
TERMINAL_PRECEDENCE = tuple(TERMINAL_MAPPING)
|
|
|
class EvidenceState(str, Enum):
|
N = "N"
|
V = "V"
|
I = "I"
|
U = "U"
|
|
|
class PackageState(str, Enum):
|
P00_INIT = "P00_INIT"
|
P01_RESERVED = "P01_RESERVED"
|
P02_TRIGGER_UNKNOWN = "P02_TRIGGER_UNKNOWN"
|
P03_CACHE_MATCHED = "P03_CACHE_MATCHED"
|
P04_STAGING_PARTIAL = "P04_STAGING_PARTIAL"
|
P05_STAGING_VALID = "P05_STAGING_VALID"
|
P06_PUBLISHED = "P06_PUBLISHED"
|
P07_MANIFESTED = "P07_MANIFESTED"
|
P08_DELIVERY = "P08_DELIVERY"
|
P09_TIMING = "P09_TIMING"
|
P10_CLOSED = "P10_CLOSED"
|
|
|
class Lane(str, Enum):
|
NEW = "NEW_TRIGGER"
|
PURE = "PURE_REUSE"
|
|
|
class ItemStatus(str, Enum):
|
SUCCESS = "SUCCESS"
|
DUPLICATE = "DUPLICATE"
|
FAILED = "FAILED"
|
STOPPED = "STOPPED"
|
|
|
class QuotaState(str, Enum):
|
NONE = "NONE"
|
RESERVED = "RESERVED"
|
CONFIRMED = "CONFIRMED"
|
UNCERTAIN = "UNCERTAIN"
|
RELEASED = "RELEASED"
|
|
|
HASH_RE = re.compile(r"^[0-9a-f]{64}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,200}$")
|
|
|
TASK_KEYS_V003 = (
|
"schema_version", "contract_version", "project_id", "handoff_id", "task_id",
|
"source_role_instance_id", "source_thread_id", "target_role_instance_id",
|
"target_thread_id", "reply_thread_id", "requester", "review_owner", "mode",
|
"query", "quantity", "aliases", "analysts", "institutions", "report_types",
|
"date_range", "minimum_pages", "exclude", "source_scope", "destination",
|
"priority", "naming_requirement", "output_root", "quota_ledger", "adb_executable",
|
"pdfinfo_executable", "package_name", "cache_root", "device_serial",
|
"observed_at_utc", "total_budget_ms", "close_reserve_ms",
|
"batch_increment_budget_ms", "min_screens", "normal_max_screens",
|
"hard_max_screens", "hard_max_candidates", "performance_slot_id",
|
"performance_plan_id",
|
)
|
|
TASK_KEYS = TASK_KEYS_V003 + ("expected_reports",)
|
|
EXPECTED_REPORT_KEYS = (
|
"report_identity", "title", "institution", "report_date", "page_count",
|
)
|
|
|
@dataclass(frozen=True)
|
class TaskSpec:
|
raw: Mapping[str, Any]
|
|
@classmethod
|
def from_mapping(cls, value: Mapping[str, Any], *, exact: bool = True) -> "TaskSpec":
|
keys = tuple(value.keys())
|
schema = value.get("schema_version")
|
expected_keys = TASK_KEYS if schema == "HIBOR_FAST_TASK_SPEC_V004" else TASK_KEYS_V003
|
if schema not in {"HIBOR_FAST_TASK_SPEC_V003", "HIBOR_FAST_TASK_SPEC_V004"}:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "schema_version", "V003 or V004 required")
|
if exact and keys != expected_keys:
|
missing = [k for k in expected_keys if k not in value]
|
extra = [k for k in value if k not in expected_keys]
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "keys", f"order/missing/extra:{missing}:{extra}")
|
required = {"project_id", "task_id", "handoff_id", "mode", "query", "quantity",
|
"output_root", "quota_ledger", "adb_executable", "pdfinfo_executable",
|
"package_name", "cache_root", "total_budget_ms", "close_reserve_ms"}
|
if missing := required.difference(value):
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "keys", f"missing:{sorted(missing)}")
|
if value["project_id"] != "project-info":
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "project_id", "must be project-info")
|
if value["package_name"] != "cn.com.hibor":
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "package_name", "must be cn.com.hibor")
|
if value["cache_root"] != "/sdcard/Android/data/cn.com.hibor/files/myfile/":
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "cache_root", "unexpected cache")
|
if value["mode"] not in {"dry-run", "discover", "collect-one", "batch", "resume-postprocess"}:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "mode", "unsupported")
|
quantity = value["quantity"]
|
if type(quantity) is not int or quantity < 1 or quantity > 10:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "quantity", "must be 1..10")
|
total = value["total_budget_ms"]
|
expected = 600_000 if quantity == 1 else 600_000 + 240_000 * (quantity - 1)
|
if type(total) is not int or total != expected:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "total_budget_ms", f"expected {expected}")
|
if value["close_reserve_ms"] < 15_000 or value["close_reserve_ms"] >= total:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "close_reserve_ms", "outside safe range")
|
if type(value.get("batch_increment_budget_ms")) is not int or value["batch_increment_budget_ms"] != 240_000:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "batch_increment_budget_ms", "must be 240000")
|
if not isinstance(value.get("observed_at_utc"), str) or not value["observed_at_utc"].endswith("Z"):
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "observed_at_utc", "UTC Z timestamp required")
|
if not isinstance(value.get("source_scope"), Mapping):
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "source_scope", "mapping required")
|
for path_key in ("output_root", "quota_ledger", "adb_executable", "pdfinfo_executable"):
|
if not isinstance(value[path_key], str) or not value[path_key]:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, path_key, "non-empty string required")
|
expected_reports = value.get("expected_reports", [])
|
if not isinstance(expected_reports, list):
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "expected_reports", "array required")
|
identities: set[str] = set()
|
for index, report in enumerate(expected_reports):
|
if not isinstance(report, Mapping) or tuple(report.keys()) != EXPECTED_REPORT_KEYS:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID,
|
f"expected_reports[{index}]", "exact ordered report keys required")
|
identity = report["report_identity"]
|
if not isinstance(identity, str) or not HASH_RE.fullmatch(identity) or identity in identities:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID,
|
f"expected_reports[{index}].report_identity", "unique SHA-256 required")
|
identities.add(identity)
|
for field_name in ("title", "institution", "report_date"):
|
if not isinstance(report[field_name], str) or not report[field_name].strip():
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID,
|
f"expected_reports[{index}].{field_name}", "non-empty string required")
|
if type(report["page_count"]) is not int or report["page_count"] < 1:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID,
|
f"expected_reports[{index}].page_count", "positive INT required")
|
if value["mode"] in {"collect-one", "batch"}:
|
if schema != "HIBOR_FAST_TASK_SPEC_V004" or len(expected_reports) != quantity:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "expected_reports",
|
"V004 collection requires one exact report per item")
|
elif expected_reports:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "expected_reports",
|
"only collect-one/batch may bind reports")
|
return cls(dict(value))
|
|
def __getattr__(self, name: str) -> Any:
|
try:
|
return self.raw[name]
|
except KeyError as exc:
|
raise AttributeError(name) from exc
|
|
@property
|
def output_path(self) -> Path:
|
return Path(self.raw["output_root"])
|
|
@property
|
def quota_path(self) -> Path:
|
return Path(self.raw["quota_ledger"])
|
|
|
@dataclass(frozen=True)
|
class PackageEvidence:
|
lane: Lane
|
state: PackageState
|
staging: EvidenceState
|
final: EvidenceState
|
quota_terminal: EvidenceState
|
artifact_terminal: EvidenceState
|
manifest: EvidenceState
|
delivery: EvidenceState
|
timing: EvidenceState
|
terminal: EvidenceState
|
|
def any_unknown(self) -> bool:
|
return EvidenceState.U in (
|
self.staging, self.final, self.quota_terminal, self.artifact_terminal,
|
self.manifest, self.delivery, self.timing, self.terminal,
|
)
|
|
|
@dataclass(frozen=True)
|
class TerminalReceipt:
|
target_path: Path
|
terminal_path: Path | None
|
persist_attempted: bool
|
persisted: bool | None
|
presence: EvidenceState
|
operation_completed: bool | None
|
liveness_unknown: bool
|
stdout_emitted: bool
|
bytes: int | None
|
sha256: str | None
|
error_code: ErrorCode | None
|
source_state: PackageState
|
|
def validate(self) -> None:
|
if self.presence is EvidenceState.V:
|
valid = (self.persist_attempted and self.persisted is True and
|
self.operation_completed is True and not self.liveness_unknown and
|
self.terminal_path == self.target_path and self.bytes is not None and
|
self.bytes >= 2 and self.sha256 is not None and HASH_RE.fullmatch(self.sha256) and
|
self.error_code is None)
|
elif self.presence is EvidenceState.U:
|
pair = ((self.operation_completed is True and not self.liveness_unknown and
|
self.error_code is ErrorCode.RECOVERY_UNKNOWN) or
|
(self.operation_completed is None and self.liveness_unknown and
|
self.error_code is ErrorCode.PROCESS_LIVENESS_UNKNOWN))
|
valid = (self.persist_attempted and self.persisted is None and pair and
|
self.terminal_path is None and self.bytes is None and self.sha256 is None)
|
elif self.presence is EvidenceState.I:
|
known_pair = (self.bytes is None and self.sha256 is None) or (
|
self.bytes is not None and self.sha256 is not None and HASH_RE.fullmatch(self.sha256))
|
valid = (self.persist_attempted and self.persisted is False and
|
self.operation_completed is True and not self.liveness_unknown and
|
self.terminal_path is None and self.error_code is ErrorCode.PERSIST_LATE and known_pair)
|
else:
|
if self.persist_attempted:
|
valid = (self.persisted is False and self.operation_completed is True and
|
not self.liveness_unknown and self.terminal_path is None and
|
self.bytes is None and self.sha256 is None and
|
self.error_code is ErrorCode.PERSIST_LATE)
|
else:
|
valid = (self.persisted is False and self.operation_completed is False and
|
not self.liveness_unknown and self.terminal_path is None and
|
self.bytes is None and self.sha256 is None)
|
if not valid:
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "TerminalReceipt", "cross-field mismatch")
|
|
|
@dataclass(frozen=True)
|
class Candidate:
|
candidate_id: str
|
title: str
|
institution: str | None
|
report_date: str | None
|
analysts: tuple[str, ...] = field(default_factory=tuple)
|
page_count: int | None = None
|
score: int = 0
|
bounds: tuple[int, int, int, int] | None = None
|
|
def __post_init__(self) -> None:
|
if not ID_RE.fullmatch(self.candidate_id):
|
raise ContractError(ErrorCode.TASK_SPEC_INVALID, "candidate_id", "invalid")
|