| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785 |
- """Candidate command service with one root-level consistency boundary."""
- from __future__ import annotations
- import asyncio
- from hashlib import sha256
- from typing import Any
- from weakref import WeakValueDictionary
- from cyber_agent.application.candidate import (
- CandidateAdoptionReceipt,
- CandidateAdoptionRequest,
- CandidateLedger,
- CandidateLifecycleRecord,
- CandidateOperationRecord,
- CandidatePointer,
- CandidateRef,
- CandidateRepository,
- CandidateReviewAction,
- CandidateVersionRequest,
- )
- from cyber_agent.application.models import canonical_json
- from cyber_agent.application.quality import CandidateValidationRecord
- from cyber_agent.core.agent_mode import require_mutable_trace_policy
- from cyber_agent.core.artifacts import ArtifactRef, ArtifactResolver, resolve_artifact_refs
- from cyber_agent.core.task_protocol import ensure_task_protocol, task_contract_ref
- class CandidateStateError(ValueError):
- pass
- class CandidateService:
- """Validate, persist, and recover candidate version operations."""
- _locks: "WeakValueDictionary[str, asyncio.Lock]" = WeakValueDictionary()
- def __init__(
- self,
- *,
- store: Any,
- application_binding: Any,
- repository: CandidateRepository,
- artifact_resolver: ArtifactResolver,
- event_service: Any = None,
- ) -> None:
- self.store = store
- self.binding = application_binding
- self.repository = repository
- self.artifact_resolver = artifact_resolver
- self.event_service = event_service
- @classmethod
- def _lock_for(cls, root_trace_id: str) -> asyncio.Lock:
- lock = cls._locks.get(root_trace_id)
- if lock is None:
- lock = asyncio.Lock()
- cls._locks[root_trace_id] = lock
- return lock
- async def manage(
- self,
- trace_id: str,
- *,
- operation: str,
- content: Any,
- parent_refs: list[CandidatePointer],
- effective_at_sequence: int,
- ) -> CandidateRef:
- trace, root = await self._require_owner_trace(trace_id)
- root_trace_id = root.trace_id
- operation_id = f"candidate:{trace_id}:{effective_at_sequence}"
- input_payload = {
- "operation": operation,
- "content": content,
- "parent_refs": [item.model_dump(mode="json") for item in parent_refs],
- }
- input_hash = sha256(
- canonical_json(input_payload).encode("utf-8")
- ).hexdigest()
- async with self._lock_for(root_trace_id):
- ledger = await self._load_ledger(root_trace_id)
- existing_operation = next(
- (
- item
- for item in ledger.operations
- if item.operation_id == operation_id
- ),
- None,
- )
- if existing_operation is not None:
- if existing_operation.input_hash != input_hash:
- raise CandidateStateError(
- "Candidate operation payload changed during recovery"
- )
- if existing_operation.status == "completed":
- if existing_operation.candidate is None:
- raise CandidateStateError(
- "Completed candidate operation has no result"
- )
- return ledger.candidate(existing_operation.candidate)
- if existing_operation.status == "failed":
- raise CandidateStateError(
- existing_operation.error or "Candidate operation failed"
- )
- else:
- try:
- pointer = self._allocate_pointer(
- operation,
- trace_id,
- effective_at_sequence,
- parent_refs,
- ledger,
- )
- except ValueError as exc:
- raise CandidateStateError(str(exc)) from exc
- operation_record = CandidateOperationRecord(
- operation_id=operation_id,
- operation=operation,
- input_hash=input_hash,
- candidate=pointer,
- )
- ledger = ledger.model_copy(update={
- "operations": (*ledger.operations, operation_record),
- })
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- existing_operation = operation_record
- assert existing_operation.candidate is not None
- pointer = existing_operation.candidate
- try:
- parents = [ledger.candidate(item) for item in parent_refs]
- self._validate_parents(trace, root, parents)
- state = ensure_task_protocol(trace.context)
- request = CandidateVersionRequest(
- operation_id=operation_id,
- application_ref=self.binding.application_ref,
- root_trace_id=root_trace_id,
- owner_trace_id=trace_id,
- uid=trace.uid,
- candidate_id=pointer.candidate_id,
- revision=pointer.revision,
- task_contract_ref=task_contract_ref(
- state,
- root_task_anchor_hash=trace.context.get(
- "root_task_anchor_hash"
- ),
- ),
- parent_refs=tuple(parent_refs),
- content=content,
- )
- artifact_ref = ArtifactRef.model_validate(
- await self.repository.merge(request)
- if operation == "merge"
- else await self.repository.put_version(request)
- )
- materials, issues = await resolve_artifact_refs(
- [artifact_ref],
- resolver=self.artifact_resolver,
- root_trace_id=root_trace_id,
- uid=trace.uid,
- )
- if issues or len(materials) != 1:
- reason = issues[0].reason if issues else "Artifact was not resolved"
- raise CandidateStateError(
- f"CandidateRepository returned an invalid ArtifactRef: {reason}"
- )
- candidate_ref = CandidateRef(
- **pointer.model_dump(mode="json"),
- application_ref=self.binding.application_ref,
- root_trace_id=root_trace_id,
- owner_trace_id=trace_id,
- task_contract_ref=request.task_contract_ref,
- artifact_ref=artifact_ref,
- parent_refs=tuple(parent_refs),
- created_at_sequence=effective_at_sequence,
- )
- except Exception as exc:
- failed = self._fail_operation(ledger, operation_id, str(exc))
- await self.store.replace_candidate_ledger(
- root_trace_id,
- failed.model_dump(mode="json"),
- )
- raise
- completed = self._complete_operation(
- ledger,
- operation_id,
- candidate_ref,
- )
- # If this atomic publish fails after the Repository committed, the
- # pending operation remains recoverable. Re-execution uses the same
- # operation_id and the Repository's idempotency contract.
- await self.store.replace_candidate_ledger(
- root_trace_id,
- completed.model_dump(mode="json"),
- )
- await self._emit_candidate_registered(candidate_ref)
- return candidate_ref
- async def validate_report_refs(
- self,
- trace_id: str,
- refs: list[CandidateRef],
- ) -> None:
- trace, root = await self._require_owner_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- for ref in refs:
- persisted = ledger.candidate(ref)
- if persisted != ref:
- raise CandidateStateError(
- "TaskReport CandidateRef does not match the root ledger"
- )
- self._validate_candidate_owner(persisted, trace, root)
- if ledger.current_state(ref) != "proposed":
- raise CandidateStateError(
- "TaskReport may only reference proposed candidates"
- )
- async def resolve_for_validation(
- self,
- trace_id: str,
- candidate_ref: CandidateRef,
- ):
- """Resolve one exact report-owned candidate through the trusted resolver."""
- trace, root = await self._require_bound_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- persisted = ledger.candidate(candidate_ref)
- if persisted != candidate_ref:
- raise CandidateStateError(
- "CandidateRef does not match the root ledger"
- )
- self._validate_candidate_owner(persisted, trace, root)
- materials, issues = await resolve_artifact_refs(
- [candidate_ref.artifact_ref],
- resolver=self.artifact_resolver,
- root_trace_id=root.trace_id,
- uid=trace.uid,
- )
- if issues or len(materials) != 1:
- reason = issues[0].reason if issues else "Artifact was not resolved"
- raise CandidateStateError(
- f"Candidate ArtifactRef cannot be validated: {reason}"
- )
- return materials[0]
- async def cached_validation(
- self,
- trace_id: str,
- candidate_ref: CandidateRef,
- plan_hash: str,
- ) -> CandidateValidationRecord | None:
- trace, root = await self._require_bound_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- persisted = ledger.candidate(candidate_ref)
- self._validate_candidate_owner(persisted, trace, root)
- for raw in ledger.validations:
- record = CandidateValidationRecord.model_validate(raw)
- if (
- record.candidate_ref == candidate_ref
- and record.plan_hash == plan_hash
- ):
- return record
- return None
- async def record_validation(
- self,
- trace_id: str,
- record: CandidateValidationRecord,
- ) -> None:
- trace, root = await self._require_bound_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- persisted = ledger.candidate(record.candidate_ref)
- if persisted != record.candidate_ref:
- raise CandidateStateError(
- "Candidate validation references an altered CandidateRef"
- )
- self._validate_candidate_owner(persisted, trace, root)
- retained = tuple(
- raw for raw in ledger.validations
- if not (
- CandidateValidationRecord.model_validate(raw).candidate_ref
- == record.candidate_ref
- and CandidateValidationRecord.model_validate(raw).plan_hash
- == record.plan_hash
- )
- )
- updated = ledger.model_copy(update={
- "validations": (
- *retained,
- record.model_dump(mode="json"),
- ),
- })
- await self.store.replace_candidate_ledger(
- root.trace_id,
- updated.model_dump(mode="json"),
- )
- if self.event_service is not None:
- await self.event_service.emit_after_commit(
- source_trace_id=record.candidate_ref.owner_trace_id,
- event_type="validation.completed",
- event_key=(
- "validation.completed:candidate:"
- f"{record.candidate_ref.candidate_id}:"
- f"{record.candidate_ref.revision}:{record.plan_hash}"
- ),
- effective_at_sequence=record.validated_at_sequence,
- payload={
- "subject_type": "candidate",
- "candidate_ref": record.candidate_ref.model_dump(
- mode="json"
- ),
- "validation_result": record.validation_result,
- },
- )
- async def apply_review_actions(
- self,
- parent_trace_id: str,
- child_trace_id: str,
- *,
- report_refs: list[CandidateRef],
- actions: list[CandidateReviewAction],
- effective_at_sequence: int,
- ) -> list[CandidateLifecycleRecord]:
- """Apply parent-owned candidate decisions behind one root lock."""
- if not actions:
- return []
- parent = await self.store.get_trace(parent_trace_id)
- child = await self.store.get_trace(child_trace_id)
- if parent is None or child is None:
- raise CandidateStateError("Parent or child Trace not found")
- if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
- raise CandidateStateError("Candidate review requires a direct child")
- _parent, root = await self._require_bound_trace(parent_trace_id)
- child_root = child.context.get("root_trace_id") or child.trace_id
- if (
- child_root != root.trace_id
- or child.context.get("application_ref")
- != self.binding.application_ref.model_dump(mode="json")
- ):
- raise CandidateStateError("Candidate review application/root mismatch")
- report_by_key = {
- (item.candidate_id, item.revision): item for item in report_refs
- }
- action_keys = [
- (item.candidate_ref.candidate_id, item.candidate_ref.revision)
- for item in actions
- ]
- if len(action_keys) != len(set(action_keys)):
- raise CandidateStateError("Candidate review actions must be unique")
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
- for raw in ledger.validations:
- record = CandidateValidationRecord.model_validate(raw)
- key = (
- record.candidate_ref.candidate_id,
- record.candidate_ref.revision,
- )
- validation_by_key[key] = record
- for action, key in zip(actions, action_keys):
- if key not in report_by_key or report_by_key[key] != action.candidate_ref:
- raise CandidateStateError(
- "Candidate action must reference the exact child TaskReport"
- )
- persisted = ledger.candidate(action.candidate_ref)
- if persisted != action.candidate_ref or persisted.owner_trace_id != child_trace_id:
- raise CandidateStateError(
- "Candidate action references another Task or altered revision"
- )
- record = validation_by_key.get(key)
- if record is None:
- raise CandidateStateError(
- "Candidate action requires framework validation"
- )
- from cyber_agent.core.validation import ValidationResult
- outcome = ValidationResult.model_validate(
- record.validation_result
- ).outcome
- if action.action == "adopt" and outcome != "passed":
- raise CandidateStateError(
- "Only a passed candidate revision can be adopted"
- )
- if action.action == "revise" and outcome == "passed":
- raise CandidateStateError(
- "Candidate revise requires a non-passed validation"
- )
- state = ledger.current_state(action.candidate_ref)
- expected_operation_id = (
- f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
- f"{key[0]}:{key[1]}:{action.action}"
- )
- same_completed_operation = any(
- item.operation_id == expected_operation_id
- and item.status == "completed"
- for item in ledger.operations
- )
- if (
- state not in {"proposed", "adoption_pending"}
- and not same_completed_operation
- ):
- raise CandidateStateError(
- f"Candidate already has terminal state: {state}"
- )
- applied: list[CandidateLifecycleRecord] = []
- for action in actions:
- ledger, record = await self._apply_review_action(
- ledger,
- action,
- parent_trace_id=parent_trace_id,
- root_trace_id=root.trace_id,
- effective_at_sequence=effective_at_sequence,
- )
- applied.append(record)
- return applied
- async def _apply_review_action(
- self,
- ledger: CandidateLedger,
- action: CandidateReviewAction,
- *,
- parent_trace_id: str,
- root_trace_id: str,
- effective_at_sequence: int,
- ) -> tuple[CandidateLedger, CandidateLifecycleRecord]:
- pointer = CandidatePointer(
- candidate_id=action.candidate_ref.candidate_id,
- revision=action.candidate_ref.revision,
- )
- operation_id = (
- f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
- f"{pointer.candidate_id}:{pointer.revision}:{action.action}"
- )
- input_hash = sha256(
- canonical_json(action).encode("utf-8")
- ).hexdigest()
- existing = next(
- (item for item in ledger.operations if item.operation_id == operation_id),
- None,
- )
- if existing is not None:
- if existing.input_hash != input_hash:
- raise CandidateStateError("Candidate review payload changed during recovery")
- if existing.status == "completed":
- record = next(
- item for item in reversed(ledger.lifecycle)
- if item.operation_id == operation_id
- )
- return ledger, record
- if existing.status == "failed":
- raise CandidateStateError(existing.error or "Candidate review failed")
- else:
- operation = CandidateOperationRecord(
- operation_id=operation_id,
- operation=action.action,
- input_hash=input_hash,
- candidate=pointer,
- )
- ledger = ledger.model_copy(update={
- "operations": (*ledger.operations, operation),
- })
- if action.action != "adopt":
- record = CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=pointer,
- state="discarded",
- effective_at_sequence=effective_at_sequence,
- source_trace_id=parent_trace_id,
- reason=(
- f"retry_from=output: {action.reason}"
- if action.action == "revise"
- else action.reason
- ),
- )
- ledger = self._finish_review_operation(ledger, operation_id, record)
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- await self._emit_lifecycle(record, root_trace_id)
- return ledger, record
- pending = CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=pointer,
- state="adoption_pending",
- effective_at_sequence=effective_at_sequence,
- source_trace_id=parent_trace_id,
- reason=action.reason,
- )
- if ledger.current_state(pointer) != "adoption_pending":
- ledger = ledger.model_copy(update={
- "lifecycle": (*ledger.lifecycle, pending),
- })
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- await self._emit_lifecycle(pending, root_trace_id)
- try:
- receipt = CandidateAdoptionReceipt.model_validate(
- await self.repository.commit_adoption(CandidateAdoptionRequest(
- operation_id=operation_id,
- candidate_ref=action.candidate_ref,
- ))
- )
- if receipt.operation_id != operation_id or not receipt.committed:
- raise CandidateStateError("Candidate adoption receipt is invalid")
- except Exception as exc:
- failed = CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=pointer,
- state="adoption_failed",
- effective_at_sequence=effective_at_sequence,
- source_trace_id=parent_trace_id,
- reason=str(exc)[:2_000],
- )
- ledger = self._finish_review_operation(
- ledger,
- operation_id,
- failed,
- error=str(exc),
- )
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- await self._emit_lifecycle(failed, root_trace_id)
- raise
- adopted = CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=pointer,
- state="adopted",
- effective_at_sequence=effective_at_sequence,
- source_trace_id=parent_trace_id,
- reason=action.reason,
- receipt=receipt,
- )
- ledger = self._finish_review_operation(ledger, operation_id, adopted)
- # A failure here intentionally leaves the durable pending intent. The
- # Repository sees the same operation_id when the review is replayed.
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- await self._emit_lifecycle(adopted, root_trace_id)
- return ledger, adopted
- async def _emit_candidate_registered(self, candidate: CandidateRef) -> None:
- if self.event_service is None:
- return
- await self.event_service.emit_after_commit(
- source_trace_id=candidate.owner_trace_id,
- event_type="candidate.version_registered",
- event_key=(
- f"candidate.version_registered:{candidate.candidate_id}:"
- f"{candidate.revision}"
- ),
- effective_at_sequence=candidate.created_at_sequence,
- payload={"candidate_ref": candidate.model_dump(mode="json")},
- )
- await self.event_service.emit_after_commit(
- source_trace_id=candidate.owner_trace_id,
- event_type="candidate.lifecycle_changed",
- event_key=(
- f"candidate.lifecycle_changed:candidate:"
- f"{candidate.owner_trace_id}:{candidate.created_at_sequence}:proposed"
- ),
- effective_at_sequence=candidate.created_at_sequence,
- payload={"lifecycle": CandidateLifecycleRecord(
- operation_id=(
- f"candidate:{candidate.owner_trace_id}:"
- f"{candidate.created_at_sequence}"
- ),
- candidate=CandidatePointer(
- candidate_id=candidate.candidate_id,
- revision=candidate.revision,
- ),
- state="proposed",
- effective_at_sequence=candidate.created_at_sequence,
- source_trace_id=candidate.owner_trace_id,
- ).model_dump(mode="json")},
- )
- async def _emit_lifecycle(
- self,
- lifecycle: CandidateLifecycleRecord,
- root_trace_id: str,
- ) -> None:
- del root_trace_id
- if self.event_service is None or lifecycle.source_trace_id is None:
- return
- await self.event_service.emit_after_commit(
- source_trace_id=lifecycle.source_trace_id,
- event_type="candidate.lifecycle_changed",
- event_key=(
- f"candidate.lifecycle_changed:{lifecycle.operation_id}:"
- f"{lifecycle.state}"
- ),
- effective_at_sequence=lifecycle.effective_at_sequence,
- payload={"lifecycle": lifecycle.model_dump(mode="json")},
- )
- @staticmethod
- def _finish_review_operation(
- ledger: CandidateLedger,
- operation_id: str,
- lifecycle: CandidateLifecycleRecord,
- *,
- error: str | None = None,
- ) -> CandidateLedger:
- operations = tuple(
- item.model_copy(update={
- "status": "failed" if error else "completed",
- "error": error[:2_000] if error else None,
- })
- if item.operation_id == operation_id else item
- for item in ledger.operations
- )
- return ledger.model_copy(update={
- "operations": operations,
- "lifecycle": (*ledger.lifecycle, lifecycle),
- })
- async def assert_rewind_allowed(
- self,
- trace_id: str,
- after_sequence: int,
- ) -> None:
- trace, root = await self._require_bound_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- for lifecycle in ledger.lifecycle:
- if lifecycle.state != "adopted":
- continue
- candidate = ledger.candidate(lifecycle.candidate)
- crosses_review = (
- lifecycle.source_trace_id == trace_id
- and lifecycle.effective_at_sequence > after_sequence
- )
- crosses_version = (
- candidate.owner_trace_id == trace_id
- and candidate.created_at_sequence > after_sequence
- )
- if crosses_review or crosses_version:
- raise CandidateStateError(
- "Cannot rewind across a committed candidate adoption"
- )
- async def _require_owner_trace(self, trace_id: str):
- trace, root = await self._require_bound_trace(trace_id)
- state = ensure_task_protocol(trace.context)
- if state.get("task_report") is not None:
- raise CandidateStateError("Candidates cannot change after TaskReport submission")
- if state.get("pending_reviews") or state.get("next_actions"):
- raise CandidateStateError("Resolve protocol lifecycle work before candidates")
- return trace, root
- async def _require_bound_trace(self, trace_id: str):
- trace = await self.store.get_trace(trace_id)
- if trace is None:
- raise CandidateStateError(f"Trace not found: {trace_id}")
- policy = require_mutable_trace_policy(trace.context)
- if not policy.requires_task_progress:
- raise CandidateStateError(
- "Candidates require Recursive revision 3"
- )
- application_ref = trace.context.get("application_ref")
- if application_ref != self.binding.application_ref.model_dump(mode="json"):
- raise CandidateStateError("Candidate Trace application binding mismatch")
- root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
- root = trace if trace.trace_id == root_trace_id else await self.store.get_trace(
- root_trace_id
- )
- if root is None or root.uid != trace.uid:
- raise CandidateStateError("Candidate root or owner mismatch")
- if root.context.get("application_ref") != application_ref:
- raise CandidateStateError("Candidate root application mismatch")
- return trace, root
- async def _load_ledger(self, root_trace_id: str) -> CandidateLedger:
- raw = await self.store.get_candidate_ledger(root_trace_id)
- return CandidateLedger.model_validate(raw or {})
- def _allocate_pointer(
- self,
- operation: str,
- trace_id: str,
- sequence: int,
- parents: list[CandidatePointer],
- ledger: CandidateLedger,
- ) -> CandidatePointer:
- if operation == "create":
- if parents:
- raise CandidateStateError("create does not accept parent_refs")
- digest = sha256(f"{trace_id}:{sequence}".encode("utf-8")).hexdigest()
- return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
- if operation == "fork":
- if len(parents) != 1:
- raise CandidateStateError("fork requires exactly one parent")
- parent = ledger.candidate(parents[0])
- revisions = [
- item.revision
- for item in ledger.candidates
- if item.candidate_id == parent.candidate_id
- ]
- return CandidatePointer(
- candidate_id=parent.candidate_id,
- revision=max(revisions) + 1,
- )
- if operation == "merge":
- if len(parents) < 2:
- raise CandidateStateError("merge requires at least two parents")
- digest = sha256(f"{trace_id}:{sequence}:merge".encode("utf-8")).hexdigest()
- return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
- raise CandidateStateError(f"Unsupported candidate operation: {operation}")
- def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
- for parent in parents:
- self._validate_candidate_owner(parent, trace, root)
- def _validate_candidate_owner(self, candidate, trace, root) -> None:
- if (
- candidate.application_ref != self.binding.application_ref
- or candidate.root_trace_id != root.trace_id
- or candidate.owner_trace_id != trace.trace_id
- ):
- raise CandidateStateError(
- "Candidate belongs to another application, root, or Task"
- )
- @staticmethod
- def _complete_operation(
- ledger: CandidateLedger,
- operation_id: str,
- candidate: CandidateRef,
- ) -> CandidateLedger:
- operations = tuple(
- item.model_copy(update={"status": "completed", "error": None})
- if item.operation_id == operation_id
- else item
- for item in ledger.operations
- )
- lifecycle = (*ledger.lifecycle, CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=CandidatePointer(
- candidate_id=candidate.candidate_id,
- revision=candidate.revision,
- ),
- state="proposed",
- effective_at_sequence=candidate.created_at_sequence,
- source_trace_id=candidate.owner_trace_id,
- ))
- return CandidateLedger(
- candidates=(*ledger.candidates, candidate),
- lifecycle=lifecycle,
- operations=operations,
- validations=ledger.validations,
- )
- @staticmethod
- def _fail_operation(
- ledger: CandidateLedger,
- operation_id: str,
- error: str,
- ) -> CandidateLedger:
- operations = tuple(
- item.model_copy(update={
- "status": "failed",
- "error": error[:2_000],
- })
- if item.operation_id == operation_id
- else item
- for item in ledger.operations
- )
- return ledger.model_copy(update={"operations": operations})
|