feat: add return to service workflow

This commit is contained in:
lussierj committed 2026-07-29 19:02:23 +00:00
1 parent 72ad19a071
commit f2b736b08e
19 files changed
+2883 -46

No files matched your search

+14
View File
@@ -4,10 +4,14 @@ from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
RepairError,
RepairMinerNotFound,
RepairMinerNotInRepair,
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
)
@@ -15,7 +19,10 @@ from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairDetail,
RepairPostLifecycle,
RepairReason,
RepairStatus,
RepairTimeline,
@@ -30,14 +37,21 @@ __all__ = [
"BeginRepairResult",
"IncompatibleMinerLifecycle",
"InconsistentRepairState",
"RepairActiveMismatch",
"RepairActor",
"RepairActorRequired",
"RepairCompleteRequest",
"RepairCompletedPayload",
"RepairCompletionActorRequired",
"RepairConflict",
"RepairDetail",
"RepairError",
"RepairMinerNotFound",
"RepairMinerNotInRepair",
"RepairNotActive",
"RepairNotFound",
"RepairPersistenceError",
"RepairPostLifecycle",
"RepairReason",
"RepairRepository",
"RepairService",
+43
View File
@@ -62,5 +62,48 @@ class RepairActorRequired(RepairError):
super().__init__("An authenticated actor is required to begin repair")
class RepairCompletionActorRequired(RepairError):
"""Raised when Return to Service lacks an authenticated actor."""
def __init__(self):
super().__init__(
"An authenticated actor is required to return a miner to service"
)
class RepairNotActive(RepairConflict):
"""Raised when a repair is no longer active."""
def __init__(self, repair_id: object, repair_status: object):
self.repair_id = repair_id
self.repair_status = repair_status
super().__init__(
f"Repair is not active: {repair_id} ({repair_status})"
)
class RepairMinerNotInRepair(RepairConflict):
"""Raised when a repair's miner has already left repair lifecycle."""
def __init__(self, miner_id: object, lifecycle_status: object):
self.miner_id = miner_id
self.lifecycle_status = lifecycle_status
super().__init__(
"Miner is not in repair lifecycle: "
f"{miner_id} ({lifecycle_status})"
)
class RepairActiveMismatch(RepairConflict):
"""Raised when the requested repair is not the miner's active repair."""
def __init__(self, repair_id: object, miner_id: object):
self.repair_id = repair_id
self.miner_id = miner_id
super().__init__(
f"Repair is not the active repair for miner: {repair_id}"
)
class RepairPersistenceError(RepairError):
"""Raised when a required repair persistence operation fails."""
+20 -1
View File
@@ -28,8 +28,13 @@ RepairStatus = Literal[
"COMPLETED",
"CANCELLED",
]
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
RepairEventType = Literal[
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
@@ -41,6 +46,13 @@ class BeginRepairRequest(BaseModel):
notes: str | None = Field(default=None, max_length=4000)
class RepairCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_lifecycle: RepairPostLifecycle
completion_summary: str | None = Field(default=None, max_length=4000)
class RepairStartedPayload(BaseModel):
reason: RepairReason
from_lifecycle: RepairEligibleLifecycle
@@ -51,6 +63,13 @@ class InitialRepairNotePayload(BaseModel):
note: str = Field(min_length=1, max_length=4000)
class RepairCompletedPayload(BaseModel):
previous_miner_lifecycle: Literal["in_repair"] = "in_repair"
resulting_miner_lifecycle: RepairPostLifecycle
pre_repair_lifecycle: RepairEligibleLifecycle | None = None
completion_summary: str | None = Field(default=None, max_length=4000)
class BeginRepairResult(BaseModel):
repair_id: UUID
miner_id: UUID
+119 -1
View File
@@ -39,7 +39,7 @@ def default_connection_factory() -> psycopg.Connection:
class RepairRepository:
"""Persistence boundary for the atomic Begin Repair workflow."""
"""Persistence boundary for explicit atomic repair workflows."""
def __init__(
self,
@@ -224,6 +224,124 @@ class RepairRepository:
"Miner lifecycle transition did not update one record"
)
def get_repair_identity(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, miner_id
FROM repairs
WHERE id = %s
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_repair_for_update(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by,
completion_summary,
resulting_lifecycle
FROM repairs
WHERE id = %s
FOR UPDATE
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_next_event_sequence(self, repair_id: UUID) -> int:
with self._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max(sequence), 0) + 1
FROM repair_events
WHERE repair_id = %s
""",
(repair_id,),
)
return int(cur.fetchone()[0])
def get_pre_repair_lifecycle(self, repair_id: UUID) -> str | None:
with self._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"""
SELECT event.payload ->> 'from_lifecycle'
FROM repair_events AS event
WHERE event.repair_id = %s
AND event.event_type = 'REPAIR_STARTED'
ORDER BY event.sequence
LIMIT 2
""",
(repair_id,),
)
rows = cur.fetchall()
if len(rows) != 1:
return None
lifecycle_status = rows[0][0]
if lifecycle_status not in {"active", "spare"}:
return None
return str(lifecycle_status)
def complete_repair(
self,
repair_id: UUID,
*,
completed_by: UUID,
completion_summary: str | None,
resulting_lifecycle: str,
) -> dict | None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE repairs
SET status = 'COMPLETED',
completed_at = now(),
completed_by = %s,
completion_summary = %s,
resulting_lifecycle = %s
WHERE id = %s
AND status = ANY(%s)
RETURNING
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by,
completion_summary,
resulting_lifecycle
""",
(
completed_by,
completion_summary,
resulting_lifecycle,
repair_id,
list(ACTIVE_REPAIR_STATUSES),
),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+44
View File
@@ -9,6 +9,7 @@ from app.identity import require_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
RepairMinerNotFound,
RepairNotFound,
@@ -16,6 +17,7 @@ from app.repairs.exceptions import (
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResponse,
RepairCompleteRequest,
RepairDetailResponse,
RepairTimelineResponse,
)
@@ -25,6 +27,7 @@ from app.repairs.service import RepairService
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
RepairServiceDependency = Annotated[
RepairService,
@@ -32,6 +35,7 @@ RepairServiceDependency = Annotated[
]
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
@router.post(
@@ -68,6 +72,46 @@ def begin_repair(
return BeginRepairResponse(data=repair)
@router.post(
"/{repair_id}/complete",
response_model=RepairDetailResponse,
)
def complete_repair(
repair_id: UUID,
request: RepairCompleteRequest,
service: RepairServiceDependency,
current_user: RepairCompleteUser,
) -> RepairDetailResponse:
try:
repair = service.complete_repair(
repair_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RepairCompletionActorRequired as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}",
response_model=RepairDetailResponse,
+115 -24
View File
@@ -6,15 +6,22 @@ from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
RepairActorRequired,
RepairCompletionActorRequired,
RepairMinerNotFound,
RepairMinerNotInRepair,
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
@@ -24,6 +31,9 @@ from app.repairs.repository import RepairRepository
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
ACTIVE_REPAIR_STATUSES = frozenset(
{"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"}
)
REPAIR_LIFECYCLE_STATUS = "in_repair"
@@ -40,6 +50,32 @@ class RepairService:
normalized = str(note).strip()
return normalized or None
@staticmethod
def _map_repair_detail(repair: dict) -> RepairDetail:
closed_by = None
if repair["closed_by_user_id"] is not None:
closed_by = RepairActor(
user_id=repair["closed_by_user_id"],
display_name=repair["closed_by_display_name"],
)
return RepairDetail(
repair_id=repair["repair_id"],
miner_id=repair["miner_id"],
miner_asset_id=repair["miner_asset_id"],
status=repair["status"],
reason=repair["reason"],
opened_at=repair["opened_at"],
opened_by=RepairActor(
user_id=repair["opened_by_user_id"],
display_name=repair["opened_by_display_name"],
),
closed_at=repair["closed_at"],
closed_by=closed_by,
miner_lifecycle=repair["miner_lifecycle"],
latest_event_sequence=repair["latest_event_sequence"],
)
def begin_repair(
self,
request: BeginRepairRequest,
@@ -107,34 +143,89 @@ class RepairService:
opened_by=repair["opened_by"],
)
def complete_repair(
self,
repair_id: UUID,
request: RepairCompleteRequest,
*,
actor_user_id: UUID | None,
) -> RepairDetail:
if actor_user_id is None:
raise RepairCompletionActorRequired()
completion_summary = self.normalize_note(request.completion_summary)
with self.repository.transaction() as repository:
repair_identity = repository.get_repair_identity(repair_id)
if repair_identity is None:
raise RepairNotFound(repair_id)
miner_id = repair_identity["miner_id"]
# Begin Repair also locks the miner first. Keeping this order makes
# competing lifecycle workflows serialize on the durable asset.
miner = repository.lock_miner_for_update(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
repair = repository.lock_repair_for_update(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
if repair["miner_id"] != miner_id:
raise RepairActiveMismatch(repair_id, miner_id)
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
raise RepairNotActive(repair_id, repair["status"])
active_repair = repository.get_active_repair(miner_id)
if active_repair is None or active_repair["id"] != repair_id:
raise RepairActiveMismatch(repair_id, miner_id)
lifecycle_status = str(miner["lifecycle_status"])
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
next_sequence = repository.get_next_event_sequence(repair_id)
pre_repair_lifecycle = repository.get_pre_repair_lifecycle(
repair_id
)
completed = repository.complete_repair(
repair_id,
completed_by=actor_user_id,
completion_summary=completion_summary,
resulting_lifecycle=request.target_lifecycle,
)
if completed is None:
raise RepairNotActive(repair_id, repair["status"])
repository.set_miner_lifecycle(
miner_id,
request.target_lifecycle,
)
payload = RepairCompletedPayload(
resulting_miner_lifecycle=request.target_lifecycle,
pre_repair_lifecycle=pre_repair_lifecycle,
completion_summary=completion_summary,
)
repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_COMPLETED",
created_by=actor_user_id,
payload=payload.model_dump(mode="json", exclude_none=True),
)
detail = repository.get_repair_detail(repair_id)
if detail is None:
raise RepairPersistenceError(
"Completed repair could not be read in its transaction"
)
return self._map_repair_detail(detail)
def get_repair(self, repair_id: UUID) -> RepairDetail:
repair = self.repository.get_repair_detail(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
closed_by = None
if repair["closed_by_user_id"] is not None:
closed_by = RepairActor(
user_id=repair["closed_by_user_id"],
display_name=repair["closed_by_display_name"],
)
return RepairDetail(
repair_id=repair["repair_id"],
miner_id=repair["miner_id"],
miner_asset_id=repair["miner_asset_id"],
status=repair["status"],
reason=repair["reason"],
opened_at=repair["opened_at"],
opened_by=RepairActor(
user_id=repair["opened_by_user_id"],
display_name=repair["opened_by_display_name"],
),
closed_at=repair["closed_at"],
closed_by=closed_by,
miner_lifecycle=repair["miner_lifecycle"],
latest_event_sequence=repair["latest_event_sequence"],
)
return self._map_repair_detail(repair)
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
events = self.repository.get_repair_events(repair_id)