candidate.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Application-owned candidate content behind framework-owned immutable references."""
  2. from __future__ import annotations
  3. from typing import Any, Literal, Protocol, runtime_checkable
  4. from pydantic import Field, model_validator
  5. from cyber_agent.application.models import ApplicationModel, ApplicationRef, canonical_json
  6. from cyber_agent.core.artifacts import ArtifactRef
  7. from cyber_agent.core.task_contract import TaskContractRef
  8. MAX_CANDIDATES_PER_ROOT = 256
  9. MAX_CANDIDATE_OPERATIONS_PER_ROOT = 2_048
  10. class CandidatePointer(ApplicationModel):
  11. candidate_id: str = Field(min_length=1, max_length=200)
  12. revision: int = Field(ge=1)
  13. class CandidateRef(CandidatePointer):
  14. schema_version: Literal[1] = 1
  15. application_ref: ApplicationRef
  16. root_trace_id: str = Field(min_length=1)
  17. owner_trace_id: str = Field(min_length=1)
  18. task_contract_ref: TaskContractRef
  19. artifact_ref: ArtifactRef
  20. parent_refs: tuple[CandidatePointer, ...] = ()
  21. created_at_sequence: int = Field(ge=0)
  22. @model_validator(mode="after")
  23. def validate_parent_refs(self) -> "CandidateRef":
  24. identities = [
  25. (item.candidate_id, item.revision)
  26. for item in self.parent_refs
  27. ]
  28. if len(identities) != len(set(identities)):
  29. raise ValueError("CandidateRef parent_refs must be unique")
  30. if (self.candidate_id, self.revision) in set(identities):
  31. raise ValueError("CandidateRef cannot reference itself")
  32. return self
  33. class CandidateVersionRequest(ApplicationModel):
  34. operation_id: str = Field(min_length=1, max_length=300)
  35. application_ref: ApplicationRef
  36. root_trace_id: str = Field(min_length=1)
  37. owner_trace_id: str = Field(min_length=1)
  38. uid: str | None = None
  39. candidate_id: str = Field(min_length=1, max_length=200)
  40. revision: int = Field(ge=1)
  41. task_contract_ref: TaskContractRef
  42. parent_refs: tuple[CandidatePointer, ...] = ()
  43. content: Any
  44. @model_validator(mode="after")
  45. def validate_content(self) -> "CandidateVersionRequest":
  46. canonical_json(self.content)
  47. return self
  48. class CandidateAdoptionRequest(ApplicationModel):
  49. operation_id: str = Field(min_length=1, max_length=300)
  50. candidate_ref: CandidateRef
  51. class CandidateAdoptionReceipt(ApplicationModel):
  52. operation_id: str = Field(min_length=1, max_length=300)
  53. external_id: str = Field(min_length=1, max_length=500)
  54. committed: bool = True
  55. class CandidateReviewAction(ApplicationModel):
  56. action: Literal["adopt", "discard", "revise"]
  57. candidate_ref: CandidateRef
  58. reason: str = Field(min_length=1, max_length=2_000)
  59. @runtime_checkable
  60. class CandidateRepository(Protocol):
  61. async def put_version(
  62. self,
  63. request: CandidateVersionRequest,
  64. ) -> ArtifactRef: ...
  65. async def load_version(self, candidate_ref: CandidateRef) -> Any: ...
  66. async def merge(
  67. self,
  68. request: CandidateVersionRequest,
  69. ) -> ArtifactRef: ...
  70. async def commit_adoption(
  71. self,
  72. request: CandidateAdoptionRequest,
  73. ) -> CandidateAdoptionReceipt: ...
  74. class CandidateLifecycleRecord(ApplicationModel):
  75. operation_id: str = Field(min_length=1, max_length=300)
  76. candidate: CandidatePointer
  77. state: Literal[
  78. "proposed",
  79. "adoption_pending",
  80. "adopted",
  81. "discarded",
  82. "adoption_failed",
  83. ]
  84. effective_at_sequence: int = Field(ge=0)
  85. source_trace_id: str | None = None
  86. reason: str | None = Field(default=None, max_length=2_000)
  87. receipt: CandidateAdoptionReceipt | None = None
  88. class CandidateOperationRecord(ApplicationModel):
  89. operation_id: str = Field(min_length=1, max_length=300)
  90. operation: Literal[
  91. "create", "fork", "merge", "adopt", "discard", "revise"
  92. ]
  93. input_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  94. status: Literal["pending", "completed", "failed"] = "pending"
  95. candidate: CandidatePointer | None = None
  96. error: str | None = Field(default=None, max_length=2_000)
  97. class CandidateLedger(ApplicationModel):
  98. schema_version: Literal[1] = 1
  99. candidates: tuple[CandidateRef, ...] = ()
  100. lifecycle: tuple[CandidateLifecycleRecord, ...] = ()
  101. operations: tuple[CandidateOperationRecord, ...] = ()
  102. validations: tuple[dict[str, Any], ...] = ()
  103. @model_validator(mode="after")
  104. def validate_ledger(self) -> "CandidateLedger":
  105. if len(self.candidates) > MAX_CANDIDATES_PER_ROOT:
  106. raise ValueError("Candidate ledger candidate limit exceeded")
  107. if len(self.operations) > MAX_CANDIDATE_OPERATIONS_PER_ROOT:
  108. raise ValueError("Candidate ledger operation limit exceeded")
  109. identities = [
  110. (item.candidate_id, item.revision)
  111. for item in self.candidates
  112. ]
  113. if len(identities) != len(set(identities)):
  114. raise ValueError("Candidate ledger contains duplicate revisions")
  115. operation_ids = [item.operation_id for item in self.operations]
  116. if len(operation_ids) != len(set(operation_ids)):
  117. raise ValueError("Candidate ledger contains duplicate operation IDs")
  118. known = set(identities)
  119. for candidate in self.candidates:
  120. for parent in candidate.parent_refs:
  121. parent_key = (parent.candidate_id, parent.revision)
  122. if parent_key not in known:
  123. raise ValueError("Candidate ledger contains an unknown parent")
  124. if (
  125. parent.candidate_id == candidate.candidate_id
  126. and parent.revision >= candidate.revision
  127. ):
  128. raise ValueError("Candidate lineage references a future revision")
  129. return self
  130. def candidate(self, pointer: CandidatePointer) -> CandidateRef:
  131. for item in self.candidates:
  132. if (
  133. item.candidate_id == pointer.candidate_id
  134. and item.revision == pointer.revision
  135. ):
  136. return item
  137. raise ValueError(
  138. f"Candidate revision not found: {pointer.candidate_id}@{pointer.revision}"
  139. )
  140. def current_state(self, pointer: CandidatePointer) -> str | None:
  141. state = None
  142. for item in self.lifecycle:
  143. if (
  144. item.candidate.candidate_id == pointer.candidate_id
  145. and item.candidate.revision == pointer.revision
  146. ):
  147. state = item.state
  148. return state