feat: add return to service workflow
This commit is contained in:
1 parent
72ad19a071
commit
f2b736b08e
19 files changed
+2883
-46
No files matched your search
@@ -0,0 +1,253 @@
|
|||||||
|
"""add return to service workflow
|
||||||
|
|
||||||
|
Revision ID: 0012_return_to_service
|
||||||
|
Revises: 0011_repair_timeline
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0012_return_to_service"
|
||||||
|
down_revision: Union[str, None] = "0011_repair_timeline"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
REPAIR_REASONS = (
|
||||||
|
"HARDWARE_FAILURE",
|
||||||
|
"LOW_HASHRATE",
|
||||||
|
"CONTROL_BOARD",
|
||||||
|
"HASHBOARD",
|
||||||
|
"PSU",
|
||||||
|
"FAN",
|
||||||
|
"OVERHEATING",
|
||||||
|
"NETWORKING",
|
||||||
|
"PREVENTIVE_MAINTENANCE",
|
||||||
|
"INSPECTION",
|
||||||
|
"OTHER",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted(values: Sequence[str]) -> str:
|
||||||
|
return ", ".join(f"'{value}'" for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_event_constraints() -> None:
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_type",
|
||||||
|
"repair_events",
|
||||||
|
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', "
|
||||||
|
"'REPAIR_COMPLETED')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_payload_shape",
|
||||||
|
"repair_events",
|
||||||
|
"("
|
||||||
|
"event_type = 'REPAIR_STARTED' "
|
||||||
|
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||||
|
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||||
|
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||||
|
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||||
|
") OR ("
|
||||||
|
"event_type = 'INITIAL_NOTE' "
|
||||||
|
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||||
|
"AND trim(payload ->> 'note') <> ''"
|
||||||
|
") OR ("
|
||||||
|
"event_type = 'REPAIR_COMPLETED' "
|
||||||
|
"AND payload ?& ARRAY["
|
||||||
|
"'previous_miner_lifecycle', 'resulting_miner_lifecycle'"
|
||||||
|
"] "
|
||||||
|
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||||
|
"AND payload ->> 'resulting_miner_lifecycle' "
|
||||||
|
"IN ('active', 'spare') "
|
||||||
|
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||||
|
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||||
|
"AND (NOT payload ? 'completion_summary' OR ("
|
||||||
|
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||||
|
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||||
|
"AND char_length(payload ->> 'completion_summary') <= 4000"
|
||||||
|
"))"
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_previous_event_constraints() -> None:
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_type",
|
||||||
|
"repair_events",
|
||||||
|
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_payload_shape",
|
||||||
|
"repair_events",
|
||||||
|
"("
|
||||||
|
"event_type = 'REPAIR_STARTED' "
|
||||||
|
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||||
|
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||||
|
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||||
|
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||||
|
") OR ("
|
||||||
|
"event_type = 'INITIAL_NOTE' "
|
||||||
|
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||||
|
"AND trim(payload ->> 'note') <> ''"
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES ('repairs.complete', 'Return repaired miners to service')
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET description = EXCLUDED.description
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.key = 'repairs.complete'
|
||||||
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"repairs",
|
||||||
|
sa.Column("completion_summary", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"repairs",
|
||||||
|
sa.Column("resulting_lifecycle", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repairs_resulting_lifecycle",
|
||||||
|
"repairs",
|
||||||
|
"resulting_lifecycle IS NULL "
|
||||||
|
"OR resulting_lifecycle IN ('active', 'spare')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repairs_completion_summary",
|
||||||
|
"repairs",
|
||||||
|
"completion_summary IS NULL OR ("
|
||||||
|
"trim(completion_summary) <> '' "
|
||||||
|
"AND char_length(completion_summary) <= 4000"
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
# NOT VALID preserves truthful legacy COMPLETED rows whose destination was
|
||||||
|
# never recorded, while PostgreSQL still enforces the check for every new
|
||||||
|
# or updated row after this migration.
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE repairs
|
||||||
|
ADD CONSTRAINT ck_repairs_completion_metadata_state
|
||||||
|
CHECK (
|
||||||
|
(
|
||||||
|
status = 'COMPLETED'
|
||||||
|
AND resulting_lifecycle IS NOT NULL
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
status <> 'COMPLETED'
|
||||||
|
AND completion_summary IS NULL
|
||||||
|
AND resulting_lifecycle IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NOT VALID
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repair_events_payload_shape",
|
||||||
|
"repair_events",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repair_events_type",
|
||||||
|
"repair_events",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
_create_event_constraints()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
completion_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*)
|
||||||
|
INTO completion_count
|
||||||
|
FROM repairs
|
||||||
|
WHERE resulting_lifecycle IS NOT NULL
|
||||||
|
OR completion_summary IS NOT NULL;
|
||||||
|
|
||||||
|
completion_count := completion_count + (
|
||||||
|
SELECT count(*)
|
||||||
|
FROM repair_events
|
||||||
|
WHERE event_type = 'REPAIR_COMPLETED'
|
||||||
|
);
|
||||||
|
|
||||||
|
IF completion_count > 0 THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE = format(
|
||||||
|
'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history',
|
||||||
|
completion_count
|
||||||
|
),
|
||||||
|
HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repair_events_payload_shape",
|
||||||
|
"repair_events",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repair_events_type",
|
||||||
|
"repair_events",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
_create_previous_event_constraints()
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repairs_completion_metadata_state",
|
||||||
|
"repairs",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repairs_completion_summary",
|
||||||
|
"repairs",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_repairs_resulting_lifecycle",
|
||||||
|
"repairs",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_column("repairs", "resulting_lifecycle")
|
||||||
|
op.drop_column("repairs", "completion_summary")
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id = (
|
||||||
|
SELECT id FROM permissions WHERE key = 'repairs.complete'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"DELETE FROM permissions WHERE key = 'repairs.complete'"
|
||||||
|
)
|
||||||
@@ -682,11 +682,22 @@ def repair_timeline_request(path: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_completion_request(path: str) -> bool:
|
||||||
|
parts = path.split("/")
|
||||||
|
return (
|
||||||
|
len(parts) == 5
|
||||||
|
and parts[:3] == ["", "api", "repairs"]
|
||||||
|
and parts[4] == "complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def required_api_permission(method: str, path: str) -> str | None:
|
def required_api_permission(method: str, path: str) -> str | None:
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
return "users.manage"
|
return "users.manage"
|
||||||
if method == "POST" and path == "/api/repairs":
|
if method == "POST" and path == "/api/repairs":
|
||||||
return "repairs.create"
|
return "repairs.create"
|
||||||
|
if method == "POST" and repair_completion_request(path):
|
||||||
|
return "repairs.complete"
|
||||||
if method == "GET" and repair_timeline_request(path):
|
if method == "GET" and repair_timeline_request(path):
|
||||||
return "repairs.read"
|
return "repairs.read"
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ from app.repairs.exceptions import (
|
|||||||
ActiveRepairConflict,
|
ActiveRepairConflict,
|
||||||
IncompatibleMinerLifecycle,
|
IncompatibleMinerLifecycle,
|
||||||
InconsistentRepairState,
|
InconsistentRepairState,
|
||||||
|
RepairActiveMismatch,
|
||||||
RepairActorRequired,
|
RepairActorRequired,
|
||||||
|
RepairCompletionActorRequired,
|
||||||
RepairConflict,
|
RepairConflict,
|
||||||
RepairError,
|
RepairError,
|
||||||
RepairMinerNotFound,
|
RepairMinerNotFound,
|
||||||
|
RepairMinerNotInRepair,
|
||||||
|
RepairNotActive,
|
||||||
RepairNotFound,
|
RepairNotFound,
|
||||||
RepairPersistenceError,
|
RepairPersistenceError,
|
||||||
)
|
)
|
||||||
@@ -15,7 +19,10 @@ from app.repairs.models import (
|
|||||||
BeginRepairRequest,
|
BeginRepairRequest,
|
||||||
BeginRepairResult,
|
BeginRepairResult,
|
||||||
RepairActor,
|
RepairActor,
|
||||||
|
RepairCompleteRequest,
|
||||||
|
RepairCompletedPayload,
|
||||||
RepairDetail,
|
RepairDetail,
|
||||||
|
RepairPostLifecycle,
|
||||||
RepairReason,
|
RepairReason,
|
||||||
RepairStatus,
|
RepairStatus,
|
||||||
RepairTimeline,
|
RepairTimeline,
|
||||||
@@ -30,14 +37,21 @@ __all__ = [
|
|||||||
"BeginRepairResult",
|
"BeginRepairResult",
|
||||||
"IncompatibleMinerLifecycle",
|
"IncompatibleMinerLifecycle",
|
||||||
"InconsistentRepairState",
|
"InconsistentRepairState",
|
||||||
|
"RepairActiveMismatch",
|
||||||
"RepairActor",
|
"RepairActor",
|
||||||
"RepairActorRequired",
|
"RepairActorRequired",
|
||||||
|
"RepairCompleteRequest",
|
||||||
|
"RepairCompletedPayload",
|
||||||
|
"RepairCompletionActorRequired",
|
||||||
"RepairConflict",
|
"RepairConflict",
|
||||||
"RepairDetail",
|
"RepairDetail",
|
||||||
"RepairError",
|
"RepairError",
|
||||||
"RepairMinerNotFound",
|
"RepairMinerNotFound",
|
||||||
|
"RepairMinerNotInRepair",
|
||||||
|
"RepairNotActive",
|
||||||
"RepairNotFound",
|
"RepairNotFound",
|
||||||
"RepairPersistenceError",
|
"RepairPersistenceError",
|
||||||
|
"RepairPostLifecycle",
|
||||||
"RepairReason",
|
"RepairReason",
|
||||||
"RepairRepository",
|
"RepairRepository",
|
||||||
"RepairService",
|
"RepairService",
|
||||||
|
|||||||
@@ -62,5 +62,48 @@ class RepairActorRequired(RepairError):
|
|||||||
super().__init__("An authenticated actor is required to begin repair")
|
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):
|
class RepairPersistenceError(RepairError):
|
||||||
"""Raised when a required repair persistence operation fails."""
|
"""Raised when a required repair persistence operation fails."""
|
||||||
@@ -28,8 +28,13 @@ RepairStatus = Literal[
|
|||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
]
|
]
|
||||||
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
RepairEventType = Literal[
|
||||||
|
"REPAIR_STARTED",
|
||||||
|
"INITIAL_NOTE",
|
||||||
|
"REPAIR_COMPLETED",
|
||||||
|
]
|
||||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||||
|
RepairPostLifecycle = Literal["active", "spare"]
|
||||||
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
|
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +46,13 @@ class BeginRepairRequest(BaseModel):
|
|||||||
notes: str | None = Field(default=None, max_length=4000)
|
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):
|
class RepairStartedPayload(BaseModel):
|
||||||
reason: RepairReason
|
reason: RepairReason
|
||||||
from_lifecycle: RepairEligibleLifecycle
|
from_lifecycle: RepairEligibleLifecycle
|
||||||
@@ -51,6 +63,13 @@ class InitialRepairNotePayload(BaseModel):
|
|||||||
note: str = Field(min_length=1, max_length=4000)
|
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):
|
class BeginRepairResult(BaseModel):
|
||||||
repair_id: UUID
|
repair_id: UUID
|
||||||
miner_id: UUID
|
miner_id: UUID
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def default_connection_factory() -> psycopg.Connection:
|
|||||||
|
|
||||||
|
|
||||||
class RepairRepository:
|
class RepairRepository:
|
||||||
"""Persistence boundary for the atomic Begin Repair workflow."""
|
"""Persistence boundary for explicit atomic repair workflows."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -224,6 +224,124 @@ class RepairRepository:
|
|||||||
"Miner lifecycle transition did not update one record"
|
"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:
|
def get_repair_detail(self, repair_id: UUID) -> dict | None:
|
||||||
with self._connection_scope() as connection:
|
with self._connection_scope() as connection:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.identity import require_permission
|
|||||||
from app.repairs.dependencies import get_repair_service
|
from app.repairs.dependencies import get_repair_service
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
RepairActorRequired,
|
RepairActorRequired,
|
||||||
|
RepairCompletionActorRequired,
|
||||||
RepairConflict,
|
RepairConflict,
|
||||||
RepairMinerNotFound,
|
RepairMinerNotFound,
|
||||||
RepairNotFound,
|
RepairNotFound,
|
||||||
@@ -16,6 +17,7 @@ from app.repairs.exceptions import (
|
|||||||
from app.repairs.models import (
|
from app.repairs.models import (
|
||||||
BeginRepairRequest,
|
BeginRepairRequest,
|
||||||
BeginRepairResponse,
|
BeginRepairResponse,
|
||||||
|
RepairCompleteRequest,
|
||||||
RepairDetailResponse,
|
RepairDetailResponse,
|
||||||
RepairTimelineResponse,
|
RepairTimelineResponse,
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,7 @@ from app.repairs.service import RepairService
|
|||||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||||
require_repair_create = require_permission("repairs.create")
|
require_repair_create = require_permission("repairs.create")
|
||||||
require_repair_read = require_permission("repairs.read")
|
require_repair_read = require_permission("repairs.read")
|
||||||
|
require_repair_complete = require_permission("repairs.complete")
|
||||||
|
|
||||||
RepairServiceDependency = Annotated[
|
RepairServiceDependency = Annotated[
|
||||||
RepairService,
|
RepairService,
|
||||||
@@ -32,6 +35,7 @@ RepairServiceDependency = Annotated[
|
|||||||
]
|
]
|
||||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||||
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
||||||
|
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -68,6 +72,46 @@ def begin_repair(
|
|||||||
return BeginRepairResponse(data=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(
|
@router.get(
|
||||||
"/{repair_id}",
|
"/{repair_id}",
|
||||||
response_model=RepairDetailResponse,
|
response_model=RepairDetailResponse,
|
||||||
|
|||||||
+115
-24
@@ -6,15 +6,22 @@ from app.repairs.exceptions import (
|
|||||||
ActiveRepairConflict,
|
ActiveRepairConflict,
|
||||||
IncompatibleMinerLifecycle,
|
IncompatibleMinerLifecycle,
|
||||||
InconsistentRepairState,
|
InconsistentRepairState,
|
||||||
|
RepairActiveMismatch,
|
||||||
RepairActorRequired,
|
RepairActorRequired,
|
||||||
|
RepairCompletionActorRequired,
|
||||||
RepairMinerNotFound,
|
RepairMinerNotFound,
|
||||||
|
RepairMinerNotInRepair,
|
||||||
|
RepairNotActive,
|
||||||
RepairNotFound,
|
RepairNotFound,
|
||||||
|
RepairPersistenceError,
|
||||||
)
|
)
|
||||||
from app.repairs.models import (
|
from app.repairs.models import (
|
||||||
BeginRepairRequest,
|
BeginRepairRequest,
|
||||||
BeginRepairResult,
|
BeginRepairResult,
|
||||||
InitialRepairNotePayload,
|
InitialRepairNotePayload,
|
||||||
RepairActor,
|
RepairActor,
|
||||||
|
RepairCompleteRequest,
|
||||||
|
RepairCompletedPayload,
|
||||||
RepairDetail,
|
RepairDetail,
|
||||||
RepairTimeline,
|
RepairTimeline,
|
||||||
RepairTimelineEvent,
|
RepairTimelineEvent,
|
||||||
@@ -24,6 +31,9 @@ from app.repairs.repository import RepairRepository
|
|||||||
|
|
||||||
|
|
||||||
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
||||||
|
ACTIVE_REPAIR_STATUSES = frozenset(
|
||||||
|
{"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"}
|
||||||
|
)
|
||||||
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +50,32 @@ class RepairService:
|
|||||||
normalized = str(note).strip()
|
normalized = str(note).strip()
|
||||||
return normalized or None
|
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(
|
def begin_repair(
|
||||||
self,
|
self,
|
||||||
request: BeginRepairRequest,
|
request: BeginRepairRequest,
|
||||||
@@ -107,34 +143,89 @@ class RepairService:
|
|||||||
opened_by=repair["opened_by"],
|
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:
|
def get_repair(self, repair_id: UUID) -> RepairDetail:
|
||||||
repair = self.repository.get_repair_detail(repair_id)
|
repair = self.repository.get_repair_detail(repair_id)
|
||||||
if repair is None:
|
if repair is None:
|
||||||
raise RepairNotFound(repair_id)
|
raise RepairNotFound(repair_id)
|
||||||
|
return self._map_repair_detail(repair)
|
||||||
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 get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
|
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
|
||||||
events = self.repository.get_repair_events(repair_id)
|
events = self.repository.get_repair_events(repair_id)
|
||||||
|
|||||||
@@ -715,6 +715,11 @@ class RepairTimelineArchitectureTests(unittest.TestCase):
|
|||||||
"minerops_repair_parts_summary_api",
|
"minerops_repair_parts_summary_api",
|
||||||
),
|
),
|
||||||
("POST", "/api/repairs", "begin_repair"),
|
("POST", "/api/repairs", "begin_repair"),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/api/repairs/{repair_id}/complete",
|
||||||
|
"complete_repair",
|
||||||
|
),
|
||||||
("GET", "/api/repairs/{repair_id}", "get_repair"),
|
("GET", "/api/repairs/{repair_id}", "get_repair"),
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
|
|||||||
@@ -232,11 +232,13 @@ class RepairRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 422)
|
self.assertEqual(response.status_code, 422)
|
||||||
self.assertIsNone(self.service.request)
|
self.assertIsNone(self.service.request)
|
||||||
|
|
||||||
def test_router_registers_exactly_one_post_route(self):
|
def test_router_registers_exact_begin_repair_route(self):
|
||||||
routes = [
|
routes = [
|
||||||
(route.path, route.methods)
|
(route.path, route.methods)
|
||||||
for route in router.routes
|
for route in router.routes
|
||||||
if hasattr(route, "methods") and "POST" in route.methods
|
if hasattr(route, "methods")
|
||||||
|
and "POST" in route.methods
|
||||||
|
and route.path == "/api/repairs"
|
||||||
]
|
]
|
||||||
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -99,11 +99,11 @@ durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
|||||||
movement, testing, and return-to-service history become coherent operational
|
movement, testing, and return-to-service history become coherent operational
|
||||||
records rather than disconnected actions.
|
records rather than disconnected actions.
|
||||||
|
|
||||||
The Begin Repair and read-only Repair Timeline workflows are complete. An
|
The Begin Repair, read-only Repair Timeline, and Return to Service workflows are
|
||||||
authorized actor can open a repair against a durable miner UUID, record
|
complete. An authorized actor can open a repair against a durable miner UUID,
|
||||||
sequenced starting events, and move the miner into repair atomically. Authorized
|
record sequenced events, and move the miner into or out of repair atomically.
|
||||||
readers can inspect the repair and its ordered immutable events. Later repair
|
Authorized readers can inspect the repair and its ordered immutable events.
|
||||||
lifecycle workflows remain planned.
|
Later repair lifecycle workflows remain planned.
|
||||||
|
|
||||||
Technician experience is a priority. Responsive and QR-assisted workflows may
|
Technician experience is a priority. Responsive and QR-assisted workflows may
|
||||||
reduce lookup and data-entry effort where they reinforce the same asset record.
|
reduce lookup and data-entry effort where they reinforce the same asset record.
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ and immutable history.
|
|||||||
|
|
||||||
- [x] Begin Repair (`POST /api/repairs`)
|
- [x] Begin Repair (`POST /api/repairs`)
|
||||||
- [x] Repair detail and timeline read APIs
|
- [x] Repair detail and timeline read APIs
|
||||||
|
- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
- [ ] Return to Service
|
|
||||||
- [ ] Control Board Replacement
|
- [ ] Control Board Replacement
|
||||||
- [ ] Repair Parts Tracking
|
- [ ] Repair Parts Tracking
|
||||||
|
|
||||||
@@ -41,9 +41,8 @@ not make these workflows complete.
|
|||||||
|
|
||||||
## Candidate Task Order
|
## Candidate Task Order
|
||||||
|
|
||||||
1. `003-return-to-service.md`
|
1. `004-control-board-replacement.md`
|
||||||
2. `004-control-board-replacement.md`
|
2. `005-repair-parts-tracking.md`
|
||||||
3. `005-repair-parts-tracking.md`
|
|
||||||
|
|
||||||
These are proposed future files under `docs/tasks/`. They do not exist yet and
|
These are proposed future files under `docs/tasks/`. They do not exist yet and
|
||||||
must not be treated as approved specifications until they are created and
|
must not be treated as approved specifications until they are created and
|
||||||
|
|||||||
@@ -0,0 +1,814 @@
|
|||||||
|
# Task 003: Return to Service
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Ready for implementation.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Complete the MinerOps repair lifecycle by adding an explicit **Return to Service** operation that closes an active repair, records an immutable completion event, and transitions the miner from `in_repair` to an eligible post-repair lifecycle state.
|
||||||
|
|
||||||
|
The operation must be atomic, permission-controlled, auditable, safe under concurrent requests, and compatible with the existing Begin Repair and Repair Timeline implementations.
|
||||||
|
|
||||||
|
## Product Intent
|
||||||
|
|
||||||
|
A repair should not remain open indefinitely after work is complete.
|
||||||
|
|
||||||
|
An authorized operator must be able to:
|
||||||
|
|
||||||
|
1. identify the active repair,
|
||||||
|
2. confirm the miner is ready to leave repair,
|
||||||
|
3. select or confirm the resulting lifecycle state,
|
||||||
|
4. optionally record a concise completion summary,
|
||||||
|
5. close the repair,
|
||||||
|
6. transition the miner out of `in_repair`,
|
||||||
|
7. preserve immutable operational history.
|
||||||
|
|
||||||
|
This is an operational command, not generic repair editing.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Task 001 introduced the Begin Repair workflow.
|
||||||
|
|
||||||
|
Task 002 introduced repair detail and immutable repair timeline read APIs.
|
||||||
|
|
||||||
|
The current repair model supports:
|
||||||
|
|
||||||
|
- creating an active repair,
|
||||||
|
- moving a miner into `in_repair`,
|
||||||
|
- preserving the miner's pre-repair lifecycle in the repair-start event payload,
|
||||||
|
- enforcing one active repair per miner,
|
||||||
|
- reading repair details,
|
||||||
|
- reading ordered repair events.
|
||||||
|
|
||||||
|
Task 003 completes that lifecycle.
|
||||||
|
|
||||||
|
The implementation must inspect the repository and reuse current names, models, exception conventions, migration patterns, event sequencing, and transaction infrastructure rather than inventing parallel abstractions.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This task includes:
|
||||||
|
|
||||||
|
- a new `repairs.complete` permission,
|
||||||
|
- a new explicit Return to Service API,
|
||||||
|
- request and response models,
|
||||||
|
- service-layer completion workflow,
|
||||||
|
- focused repository methods,
|
||||||
|
- repair completion persistence,
|
||||||
|
- miner lifecycle transition,
|
||||||
|
- immutable completion event,
|
||||||
|
- concurrency protection,
|
||||||
|
- migration and permission assignment,
|
||||||
|
- full authorization, domain, migration, routing, and regression tests,
|
||||||
|
- release-document update after implementation passes.
|
||||||
|
|
||||||
|
## Explicitly Out of Scope
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- generic repair update or patch endpoints,
|
||||||
|
- reopening a completed repair,
|
||||||
|
- cancelling a repair,
|
||||||
|
- abandoning a repair,
|
||||||
|
- repair diagnosis fields,
|
||||||
|
- repair photos,
|
||||||
|
- repair parts consumption,
|
||||||
|
- technician assignment,
|
||||||
|
- work orders,
|
||||||
|
- control board replacement,
|
||||||
|
- MAC transfer,
|
||||||
|
- automatic monitoring confirmation,
|
||||||
|
- automated burn-in or test workflows,
|
||||||
|
- bulk Return to Service,
|
||||||
|
- notifications,
|
||||||
|
- UI changes unless the repository already requires generated API client updates,
|
||||||
|
- changes to unrelated legacy repair routes.
|
||||||
|
|
||||||
|
Those belong in later tasks.
|
||||||
|
|
||||||
|
## User and Operator Workflow
|
||||||
|
|
||||||
|
A typical flow is:
|
||||||
|
|
||||||
|
1. A technician opens the active repair.
|
||||||
|
2. The technician completes physical work and any required validation.
|
||||||
|
3. The technician invokes Return to Service.
|
||||||
|
4. The technician chooses the miner's destination lifecycle:
|
||||||
|
- the original pre-repair lifecycle, when eligible,
|
||||||
|
- or another explicitly allowed lifecycle.
|
||||||
|
5. The technician may enter a completion summary.
|
||||||
|
6. MinerOps atomically:
|
||||||
|
- verifies the repair is active,
|
||||||
|
- verifies the miner is still in repair,
|
||||||
|
- validates the destination lifecycle,
|
||||||
|
- closes the repair,
|
||||||
|
- transitions the miner,
|
||||||
|
- appends a completion event.
|
||||||
|
7. The repair becomes read-only historical data.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Add one explicit action route.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/repairs/{repair_id}/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `complete` unless existing route naming conventions make `return-to-service` materially more consistent. Do not add both aliases.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Required.
|
||||||
|
|
||||||
|
### Permission
|
||||||
|
|
||||||
|
```text
|
||||||
|
repairs.complete
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path Parameter
|
||||||
|
|
||||||
|
- `repair_id`: UUID
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
The request should contain:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target_lifecycle": "active",
|
||||||
|
"completion_summary": "Replaced failed fan and completed load test."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use repository-established lifecycle values and naming.
|
||||||
|
|
||||||
|
#### `target_lifecycle`
|
||||||
|
|
||||||
|
Required unless the service can unambiguously restore the original lifecycle under existing product rules.
|
||||||
|
|
||||||
|
Preferred design: require an explicit target lifecycle in the request so the operator confirms the resulting operational state.
|
||||||
|
|
||||||
|
Allowed target values must be restricted to valid non-repair lifecycle states.
|
||||||
|
|
||||||
|
At minimum:
|
||||||
|
|
||||||
|
- `active`
|
||||||
|
- `inactive`
|
||||||
|
|
||||||
|
Also allow other existing non-terminal lifecycle states only if they are already legitimate post-repair states in the current domain model.
|
||||||
|
|
||||||
|
Never allow:
|
||||||
|
|
||||||
|
- `in_repair`
|
||||||
|
- unknown values
|
||||||
|
- deprecated values
|
||||||
|
- terminal or destructive states that are not valid Return to Service destinations
|
||||||
|
|
||||||
|
Do not invent new lifecycle states.
|
||||||
|
|
||||||
|
#### `completion_summary`
|
||||||
|
|
||||||
|
Optional.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- trim leading and trailing whitespace,
|
||||||
|
- empty after trimming becomes `null`,
|
||||||
|
- enforce a sensible maximum length using current project conventions,
|
||||||
|
- preserve text exactly after normalization,
|
||||||
|
- do not interpret it as structured diagnosis or parts data.
|
||||||
|
|
||||||
|
### Successful Response
|
||||||
|
|
||||||
|
Return the completed repair using the existing repair-detail response model where practical.
|
||||||
|
|
||||||
|
The response must allow the client to determine:
|
||||||
|
|
||||||
|
- repair UUID,
|
||||||
|
- miner UUID,
|
||||||
|
- completed status,
|
||||||
|
- completion timestamp,
|
||||||
|
- completing actor where exposed by current conventions,
|
||||||
|
- resulting miner lifecycle,
|
||||||
|
- timeline or event count only if already part of the established response.
|
||||||
|
|
||||||
|
Do not expand the response with unrelated miner details.
|
||||||
|
|
||||||
|
### Status Codes
|
||||||
|
|
||||||
|
#### `200 OK`
|
||||||
|
|
||||||
|
Preferred when returning the completed repair representation.
|
||||||
|
|
||||||
|
Use `201` only if existing operational-command conventions clearly require it. Do not use `204` if the existing repair response can be returned cleanly.
|
||||||
|
|
||||||
|
#### `401 Unauthorized`
|
||||||
|
|
||||||
|
Unauthenticated request.
|
||||||
|
|
||||||
|
#### `403 Forbidden`
|
||||||
|
|
||||||
|
Authenticated actor lacks `repairs.complete`.
|
||||||
|
|
||||||
|
#### `404 Not Found`
|
||||||
|
|
||||||
|
The repair UUID does not exist.
|
||||||
|
|
||||||
|
Do not reveal authorization-sensitive information beyond existing project behavior.
|
||||||
|
|
||||||
|
#### `409 Conflict`
|
||||||
|
|
||||||
|
Use for valid requests that conflict with current domain state, including:
|
||||||
|
|
||||||
|
- repair is already completed,
|
||||||
|
- repair is otherwise not active,
|
||||||
|
- miner is no longer in `in_repair`,
|
||||||
|
- repair does not match the miner's active repair,
|
||||||
|
- target lifecycle is known but not eligible for Return to Service,
|
||||||
|
- a concurrent request completed the repair first.
|
||||||
|
|
||||||
|
#### `422 Unprocessable Entity`
|
||||||
|
|
||||||
|
Use for malformed UUIDs, invalid JSON, missing required fields, unknown lifecycle values, excessive summary length, or other request-validation errors.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repairs.complete
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intended Role Assignment
|
||||||
|
|
||||||
|
Follow current role conventions and migrations.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Administrator: allowed
|
||||||
|
- Technician: allowed
|
||||||
|
- Read Only / Viewer: denied
|
||||||
|
- Auditor: denied unless current policy explicitly grants operational actions
|
||||||
|
- Unauthenticated: denied
|
||||||
|
|
||||||
|
Do not grant the permission to every role by default.
|
||||||
|
|
||||||
|
Do not overload `repairs.create`.
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
### Active Repair Requirement
|
||||||
|
|
||||||
|
Only an active repair can be completed.
|
||||||
|
|
||||||
|
A completed repair cannot be completed again.
|
||||||
|
|
||||||
|
### Miner Lifecycle Requirement
|
||||||
|
|
||||||
|
The associated miner must currently be in `in_repair`.
|
||||||
|
|
||||||
|
If it is not, return a domain conflict and do not modify anything.
|
||||||
|
|
||||||
|
### Destination Lifecycle
|
||||||
|
|
||||||
|
The destination must:
|
||||||
|
|
||||||
|
- exist in the current lifecycle domain,
|
||||||
|
- not equal `in_repair`,
|
||||||
|
- be explicitly allowed as a post-repair state,
|
||||||
|
- be applied only after all validation succeeds.
|
||||||
|
|
||||||
|
### Original Lifecycle
|
||||||
|
|
||||||
|
The Begin Repair workflow preserves the previous lifecycle in the `REPAIR_STARTED` event payload.
|
||||||
|
|
||||||
|
The service may use this value for:
|
||||||
|
|
||||||
|
- validation,
|
||||||
|
- response context,
|
||||||
|
- defaulting only if the API contract and existing model conventions support it safely.
|
||||||
|
|
||||||
|
The implementation must not assume every miner should return to `active`.
|
||||||
|
|
||||||
|
If restoration of the previous lifecycle is supported, preserve exact historical meaning. Do not infer or fabricate it when missing.
|
||||||
|
|
||||||
|
### Completion Is Final
|
||||||
|
|
||||||
|
Once completed:
|
||||||
|
|
||||||
|
- the repair is historical,
|
||||||
|
- the completion event is immutable,
|
||||||
|
- the active-repair constraint is released,
|
||||||
|
- a later repair must be a new repair record.
|
||||||
|
|
||||||
|
### No Silent Repair Mutation
|
||||||
|
|
||||||
|
Do not edit prior events.
|
||||||
|
|
||||||
|
Do not replace the start event.
|
||||||
|
|
||||||
|
Do not overwrite historical lifecycle values.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Router -> Service -> Repository -> PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router Responsibilities
|
||||||
|
|
||||||
|
The router should:
|
||||||
|
|
||||||
|
- define the action route,
|
||||||
|
- validate the UUID and request model,
|
||||||
|
- enforce authentication and `repairs.complete`,
|
||||||
|
- call the service,
|
||||||
|
- map established domain exceptions to HTTP responses,
|
||||||
|
- return the established response model.
|
||||||
|
|
||||||
|
The router must not:
|
||||||
|
|
||||||
|
- contain SQL,
|
||||||
|
- manage commits or rollbacks,
|
||||||
|
- choose lifecycle transitions,
|
||||||
|
- construct event sequence values,
|
||||||
|
- read actor UUIDs from the request body.
|
||||||
|
|
||||||
|
### Service Responsibilities
|
||||||
|
|
||||||
|
The service should:
|
||||||
|
|
||||||
|
- load and lock the repair,
|
||||||
|
- verify it exists and is active,
|
||||||
|
- load and lock the associated miner when required,
|
||||||
|
- verify the miner is in repair,
|
||||||
|
- validate target lifecycle,
|
||||||
|
- normalize summary,
|
||||||
|
- close the repair,
|
||||||
|
- update miner lifecycle,
|
||||||
|
- append the completion event,
|
||||||
|
- return the completed domain result,
|
||||||
|
- ensure the operation is atomic.
|
||||||
|
|
||||||
|
### Repository Responsibilities
|
||||||
|
|
||||||
|
Repositories should provide focused methods for:
|
||||||
|
|
||||||
|
- retrieving an active repair for update,
|
||||||
|
- retrieving the associated miner for update,
|
||||||
|
- marking a repair completed,
|
||||||
|
- updating lifecycle,
|
||||||
|
- obtaining or allocating the next repair event sequence,
|
||||||
|
- appending a repair event,
|
||||||
|
- reading the completed repair representation efficiently.
|
||||||
|
|
||||||
|
Do not introduce a broad generic repair-update method.
|
||||||
|
|
||||||
|
## Database and Migration
|
||||||
|
|
||||||
|
A migration is required if the current schema does not already support all required completion data and permission assignment.
|
||||||
|
|
||||||
|
Inspect the existing repair table before adding columns.
|
||||||
|
|
||||||
|
### Preferred Repair Completion Fields
|
||||||
|
|
||||||
|
If not already present, add only the fields needed to represent completion authoritatively:
|
||||||
|
|
||||||
|
- `completed_at`: timezone-aware timestamp, nullable while active
|
||||||
|
- `completed_by_user_id`: UUID foreign key, nullable while active
|
||||||
|
- `completion_summary`: nullable text
|
||||||
|
- `resulting_lifecycle`: constrained lifecycle value, nullable while active
|
||||||
|
|
||||||
|
Use existing naming conventions if equivalent columns already exist.
|
||||||
|
|
||||||
|
### Status Representation
|
||||||
|
|
||||||
|
Do not add redundant mutable status data if active versus completed can be derived safely from established fields.
|
||||||
|
|
||||||
|
If the current repair schema already uses a status column, reuse and constrain it rather than introducing a second status mechanism.
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
Enforce critical invariants in PostgreSQL where practical.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- active repair has no completion timestamp,
|
||||||
|
- completed repair has a completion timestamp,
|
||||||
|
- completed repair has a completing actor,
|
||||||
|
- resulting lifecycle is null while active and present when completed,
|
||||||
|
- resulting lifecycle cannot equal `in_repair`,
|
||||||
|
- completion summary length is constrained if the project uses database length checks,
|
||||||
|
- one active repair per miner remains enforced.
|
||||||
|
|
||||||
|
Adapt the exact checks to the existing schema.
|
||||||
|
|
||||||
|
### Foreign Keys
|
||||||
|
|
||||||
|
`completed_by_user_id` should reference the established user table.
|
||||||
|
|
||||||
|
Use the project's existing historical-record deletion policy. Operational history should not silently disappear.
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
|
||||||
|
Add only indexes justified by actual read patterns.
|
||||||
|
|
||||||
|
Likely useful:
|
||||||
|
|
||||||
|
- existing active-repair partial uniqueness,
|
||||||
|
- lookup by miner,
|
||||||
|
- lookup by completion timestamp only if current repair queue/history queries need it.
|
||||||
|
|
||||||
|
Avoid speculative indexes.
|
||||||
|
|
||||||
|
### Migration Data Handling
|
||||||
|
|
||||||
|
Existing active repairs must remain active.
|
||||||
|
|
||||||
|
Existing completed repairs, if legacy data exists, must be handled without inventing actor IDs, summaries, or lifecycle states.
|
||||||
|
|
||||||
|
If new non-null completion invariants cannot be truthfully applied to legacy completed rows:
|
||||||
|
|
||||||
|
- use nullable historical fields with conditional checks,
|
||||||
|
- preserve legacy data,
|
||||||
|
- document the limitation,
|
||||||
|
- do not fabricate values.
|
||||||
|
|
||||||
|
### Downgrade
|
||||||
|
|
||||||
|
The downgrade must restore the previous schema safely.
|
||||||
|
|
||||||
|
Before dropping completion columns or constraints, restore miner lifecycle only if the downgrade actually reverses newly created completion data and there is a truthful historical source.
|
||||||
|
|
||||||
|
Do not map every historical completed repair to `active`.
|
||||||
|
|
||||||
|
Do not invent original lifecycle values.
|
||||||
|
|
||||||
|
If a fully safe downgrade is impossible once real completion records exist, use the project's established explicit-refusal pattern rather than destructive guessing.
|
||||||
|
|
||||||
|
## Events and History
|
||||||
|
|
||||||
|
Append one immutable completion event.
|
||||||
|
|
||||||
|
Use the existing repair event naming conventions. Preferred semantic name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REPAIR_COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuse an existing equivalent event type if already defined.
|
||||||
|
|
||||||
|
### Event Fields
|
||||||
|
|
||||||
|
The event must include:
|
||||||
|
|
||||||
|
- event UUID,
|
||||||
|
- repair UUID,
|
||||||
|
- next explicit sequence number,
|
||||||
|
- authenticated actor UUID,
|
||||||
|
- server/database timestamp,
|
||||||
|
- structured payload.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
Include only durable, meaningful values.
|
||||||
|
|
||||||
|
Recommended payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"previous_miner_lifecycle": "in_repair",
|
||||||
|
"resulting_miner_lifecycle": "active",
|
||||||
|
"pre_repair_lifecycle": "inactive",
|
||||||
|
"completion_summary": "Replaced failed fan and completed load test."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre_repair_lifecycle` may be omitted if the existing event model or service cannot retrieve it reliably without ambiguity.
|
||||||
|
|
||||||
|
Do not duplicate arbitrary repair fields in the payload.
|
||||||
|
|
||||||
|
Do not store display names as authoritative actor identity.
|
||||||
|
|
||||||
|
### Event Ordering
|
||||||
|
|
||||||
|
Use the established explicit sequence mechanism.
|
||||||
|
|
||||||
|
Do not calculate order from timestamps alone.
|
||||||
|
|
||||||
|
Concurrent event insertion must not produce duplicate sequence values.
|
||||||
|
|
||||||
|
## Models and Validation
|
||||||
|
|
||||||
|
Create or extend focused models.
|
||||||
|
|
||||||
|
Suggested names, adapted to current conventions:
|
||||||
|
|
||||||
|
- `RepairCompleteRequest`
|
||||||
|
- `RepairDetailResponse` or existing equivalent
|
||||||
|
- internal completion result model if needed
|
||||||
|
|
||||||
|
### Validation Requirements
|
||||||
|
|
||||||
|
- UUID path validation,
|
||||||
|
- finite lifecycle validation,
|
||||||
|
- summary trimming,
|
||||||
|
- empty summary normalization,
|
||||||
|
- maximum summary length,
|
||||||
|
- no client-supplied actor,
|
||||||
|
- no client-supplied completion timestamp,
|
||||||
|
- no arbitrary event payload supplied by the client.
|
||||||
|
|
||||||
|
## Concurrency and Atomicity
|
||||||
|
|
||||||
|
The entire operation must use one transaction.
|
||||||
|
|
||||||
|
The following must succeed or fail together:
|
||||||
|
|
||||||
|
1. repair validation,
|
||||||
|
2. miner validation,
|
||||||
|
3. repair completion update,
|
||||||
|
4. miner lifecycle transition,
|
||||||
|
5. completion event insertion.
|
||||||
|
|
||||||
|
### Concurrent Completion
|
||||||
|
|
||||||
|
Two simultaneous completion requests must not both succeed.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
|
||||||
|
- one request succeeds,
|
||||||
|
- the other receives `409 Conflict`,
|
||||||
|
- one completion event exists,
|
||||||
|
- one final lifecycle transition exists.
|
||||||
|
|
||||||
|
Use row locking, compare-and-update semantics, or another database-backed mechanism consistent with the repository.
|
||||||
|
|
||||||
|
### Concurrent Miner Changes
|
||||||
|
|
||||||
|
A simultaneous lifecycle-changing operation must not leave:
|
||||||
|
|
||||||
|
- a completed repair with miner still in `in_repair`,
|
||||||
|
- an active repair with miner already returned,
|
||||||
|
- conflicting lifecycle history.
|
||||||
|
|
||||||
|
Lock ordering must be consistent with Begin Repair and other miner workflows to reduce deadlock risk.
|
||||||
|
|
||||||
|
Document the chosen lock order in code comments only where it is not obvious.
|
||||||
|
|
||||||
|
## Error and Failure Behaviour
|
||||||
|
|
||||||
|
On any failure after the transaction starts:
|
||||||
|
|
||||||
|
- no partial completion fields persist,
|
||||||
|
- miner lifecycle remains unchanged,
|
||||||
|
- no completion event persists,
|
||||||
|
- repair remains active.
|
||||||
|
|
||||||
|
Unexpected database integrity failures should be translated using established service/repository exception conventions.
|
||||||
|
|
||||||
|
Do not catch broad exceptions merely to convert them into `409`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Add focused tests following current test organization.
|
||||||
|
|
||||||
|
### Authorization Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- unauthenticated request is denied,
|
||||||
|
- user without `repairs.complete` is denied,
|
||||||
|
- Read Only / Viewer is denied,
|
||||||
|
- Technician is allowed,
|
||||||
|
- Administrator is allowed,
|
||||||
|
- existing `repairs.read` behavior is unchanged,
|
||||||
|
- existing `repairs.create` behavior is unchanged.
|
||||||
|
|
||||||
|
### Happy Path Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- active repair completes successfully,
|
||||||
|
- miner transitions from `in_repair` to requested eligible lifecycle,
|
||||||
|
- repair is marked completed,
|
||||||
|
- completion timestamp is server-controlled,
|
||||||
|
- completing actor is authenticated user,
|
||||||
|
- optional summary is persisted,
|
||||||
|
- blank summary normalizes to null,
|
||||||
|
- completion event is appended,
|
||||||
|
- event sequence follows existing events,
|
||||||
|
- response represents the completed repair.
|
||||||
|
|
||||||
|
Test at least:
|
||||||
|
|
||||||
|
- return to `active`,
|
||||||
|
- return to `inactive`,
|
||||||
|
- restoration to a valid original lifecycle when relevant.
|
||||||
|
|
||||||
|
### Domain Conflict Tests
|
||||||
|
|
||||||
|
Verify `409` for:
|
||||||
|
|
||||||
|
- already completed repair,
|
||||||
|
- inactive/non-active repair,
|
||||||
|
- miner not in `in_repair`,
|
||||||
|
- mismatched active repair state,
|
||||||
|
- disallowed destination lifecycle,
|
||||||
|
- duplicate concurrent completion.
|
||||||
|
|
||||||
|
### Validation Tests
|
||||||
|
|
||||||
|
Verify `422` for:
|
||||||
|
|
||||||
|
- malformed repair UUID,
|
||||||
|
- missing required lifecycle,
|
||||||
|
- unknown lifecycle,
|
||||||
|
- `in_repair` as destination,
|
||||||
|
- summary over maximum length,
|
||||||
|
- invalid request shape.
|
||||||
|
|
||||||
|
If lifecycle defaulting is intentionally supported, replace the missing-field expectation with tests for documented default behavior.
|
||||||
|
|
||||||
|
### Missing Resource Test
|
||||||
|
|
||||||
|
Verify `404` for unknown repair UUID.
|
||||||
|
|
||||||
|
### Transaction Rollback Tests
|
||||||
|
|
||||||
|
Force failures at meaningful steps, such as:
|
||||||
|
|
||||||
|
- miner update failure,
|
||||||
|
- repair completion update failure,
|
||||||
|
- event insertion failure.
|
||||||
|
|
||||||
|
Verify no partial state persists.
|
||||||
|
|
||||||
|
### Event Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- exactly one completion event,
|
||||||
|
- immutable event behavior remains,
|
||||||
|
- actor is correct,
|
||||||
|
- payload is structured,
|
||||||
|
- ordering is explicit,
|
||||||
|
- prior events remain unchanged.
|
||||||
|
|
||||||
|
### Database Constraint Tests
|
||||||
|
|
||||||
|
Verify applicable constraints directly, including:
|
||||||
|
|
||||||
|
- completed record requirements,
|
||||||
|
- active/completed consistency,
|
||||||
|
- resulting lifecycle restriction,
|
||||||
|
- one-active-repair rule remains valid.
|
||||||
|
|
||||||
|
### Migration Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- previous head upgrades,
|
||||||
|
- new permission exists,
|
||||||
|
- intended roles receive the permission,
|
||||||
|
- unintended roles do not,
|
||||||
|
- schema constraints exist,
|
||||||
|
- downgrade works safely or refuses explicitly according to project convention,
|
||||||
|
- re-upgrade works,
|
||||||
|
- empty database upgrades through head.
|
||||||
|
|
||||||
|
### Routing Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- static repair routes such as `/api/repairs/queue` and `/api/repairs/parts-summary` still resolve,
|
||||||
|
- `/api/repairs/{repair_id}/complete` resolves correctly,
|
||||||
|
- UUID detail and event routes remain unchanged,
|
||||||
|
- malformed static-looking paths are not accidentally consumed by UUID routes,
|
||||||
|
- expected route count increases only by the intended amount.
|
||||||
|
|
||||||
|
### Regression Tests
|
||||||
|
|
||||||
|
Run at minimum:
|
||||||
|
|
||||||
|
- Begin Repair tests,
|
||||||
|
- Repair Timeline tests,
|
||||||
|
- inventory lifecycle tests,
|
||||||
|
- permission tests,
|
||||||
|
- migration lifecycle suite,
|
||||||
|
- relevant legacy repair tests,
|
||||||
|
- complete project suite if practical.
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
After implementation and validation succeed:
|
||||||
|
|
||||||
|
- mark Return to Service complete in `docs/releases/v0.2-repair-workflow.md`,
|
||||||
|
- update `docs/ROADMAP.md` only if the repository uses it as current implementation status,
|
||||||
|
- update API documentation if maintained separately.
|
||||||
|
|
||||||
|
Do not mark Control Board Replacement or Repair Parts Tracking complete.
|
||||||
|
|
||||||
|
Do not perform broad stale-document cleanup in this task unless a directly changed statement would become materially false.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The task is complete when all of the following are true:
|
||||||
|
|
||||||
|
1. `POST /api/repairs/{repair_id}/complete` exists.
|
||||||
|
2. Authentication and `repairs.complete` are enforced.
|
||||||
|
3. Only an active repair can be completed.
|
||||||
|
4. The associated miner must be in `in_repair`.
|
||||||
|
5. The target lifecycle is explicit and validated.
|
||||||
|
6. Repair completion and miner transition are atomic.
|
||||||
|
7. Exactly one immutable completion event is appended.
|
||||||
|
8. Concurrent completion cannot succeed twice.
|
||||||
|
9. Existing repair static and UUID routes still resolve.
|
||||||
|
10. Existing Begin Repair and Repair Timeline behavior remains unchanged.
|
||||||
|
11. Migration lifecycle validation passes.
|
||||||
|
12. Permission assignments are correct.
|
||||||
|
13. Focused and regression tests pass.
|
||||||
|
14. `git diff --check` passes.
|
||||||
|
15. No unrelated files are changed.
|
||||||
|
16. No commit or push is performed.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
Use repository-native commands where available.
|
||||||
|
|
||||||
|
At minimum run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m compileall .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run shell syntax checks for changed shell files, if any.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- focused Return to Service tests,
|
||||||
|
- Begin Repair tests,
|
||||||
|
- Repair Timeline tests,
|
||||||
|
- inventory lifecycle tests,
|
||||||
|
- permission tests,
|
||||||
|
- migration tests,
|
||||||
|
- route audits,
|
||||||
|
- architecture audits,
|
||||||
|
- full suite when practical.
|
||||||
|
|
||||||
|
Validate migrations through:
|
||||||
|
|
||||||
|
- previous head to new head,
|
||||||
|
- downgrade,
|
||||||
|
- re-upgrade,
|
||||||
|
- empty database to head,
|
||||||
|
- disposable development baseline.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Audit
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
- no SQL in routers,
|
||||||
|
- no database driver imports in routers,
|
||||||
|
- no transaction management in routers,
|
||||||
|
- no FastAPI imports in services or repositories,
|
||||||
|
- no generic repair update repository introduced,
|
||||||
|
- actor identity comes from authentication,
|
||||||
|
- timestamps are server-controlled,
|
||||||
|
- events are append-only,
|
||||||
|
- route ordering is safe.
|
||||||
|
|
||||||
|
## Completion Report
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
- branch and starting commit,
|
||||||
|
- files created,
|
||||||
|
- files modified,
|
||||||
|
- migration revision and parent,
|
||||||
|
- route added,
|
||||||
|
- permission added and role assignments,
|
||||||
|
- domain behavior,
|
||||||
|
- concurrency strategy,
|
||||||
|
- event type and payload,
|
||||||
|
- tests added,
|
||||||
|
- focused test results,
|
||||||
|
- regression results,
|
||||||
|
- migration validation results,
|
||||||
|
- architecture audit results,
|
||||||
|
- `git diff --check`,
|
||||||
|
- known limitations,
|
||||||
|
- final `git status --short`.
|
||||||
|
|
||||||
|
Do not commit.
|
||||||
|
Do not push.
|
||||||
@@ -57,7 +57,7 @@ echo "Upgrading the previous head to Begin Repair..."
|
|||||||
run_alembic upgrade 0009_inventory_asset_id_history
|
run_alembic upgrade 0009_inventory_asset_id_history
|
||||||
previous_revision="$(run_alembic current 2>/dev/null)"
|
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]]
|
[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]]
|
||||||
run_alembic upgrade head
|
run_alembic upgrade 0010_begin_repair
|
||||||
current_revision="$(run_alembic current 2>/dev/null)"
|
current_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0010_begin_repair"* ]]
|
[[ "${current_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
@@ -534,17 +534,17 @@ SELECT
|
|||||||
SQL
|
SQL
|
||||||
)"
|
)"
|
||||||
[[ "${downgrade_state}" == "t" ]]
|
[[ "${downgrade_state}" == "t" ]]
|
||||||
run_alembic upgrade head
|
run_alembic upgrade 0010_begin_repair
|
||||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
|
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
echo "Migrating an empty database from base through head..."
|
echo "Migrating an empty database from base through Begin Repair..."
|
||||||
"${compose[@]}" exec -T postgres sh -ec \
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||||
-c "CREATE DATABASE minerops_repair_empty"' >/dev/null
|
-c "CREATE DATABASE minerops_repair_empty"' >/dev/null
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
-e "POSTGRES_DB=${empty_database}" \
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
backend alembic upgrade head
|
backend alembic upgrade 0010_begin_repair
|
||||||
|
|
||||||
empty_revision="$(
|
empty_revision="$(
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0011_repair_timeline"* ]]
|
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
missing_tables="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ echo "Upgrading the previous head to Repair Timeline..."
|
|||||||
run_alembic upgrade 0010_begin_repair
|
run_alembic upgrade 0010_begin_repair
|
||||||
previous_revision="$(run_alembic current 2>/dev/null)"
|
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${previous_revision}" == *"0010_begin_repair"* ]]
|
[[ "${previous_revision}" == *"0010_begin_repair"* ]]
|
||||||
run_alembic upgrade head
|
run_alembic upgrade 0011_repair_timeline
|
||||||
current_revision="$(run_alembic current 2>/dev/null)"
|
current_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0011_repair_timeline"* ]]
|
[[ "${current_revision}" == *"0011_repair_timeline"* ]]
|
||||||
|
|
||||||
@@ -149,17 +149,17 @@ SQL
|
|||||||
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${downgraded_revision}" == *"0010_begin_repair"* ]]
|
[[ "${downgraded_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
run_alembic upgrade head
|
run_alembic upgrade 0011_repair_timeline
|
||||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]]
|
[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]]
|
||||||
|
|
||||||
echo "Migrating an empty database from base through head..."
|
echo "Migrating an empty database from base through Repair Timeline..."
|
||||||
"${compose[@]}" exec -T postgres sh -ec \
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||||
-c "CREATE DATABASE minerops_timeline_empty"' >/dev/null
|
-c "CREATE DATABASE minerops_timeline_empty"' >/dev/null
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
-e "POSTGRES_DB=${empty_database}" \
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
backend alembic upgrade head
|
backend alembic upgrade 0011_repair_timeline
|
||||||
|
|
||||||
empty_revision="$(
|
empty_revision="$(
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
|||||||
Executable
+295
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_RETURN_MIGRATION_PROJECT:-minerops-return-migration-$$}"
|
||||||
|
empty_database="minerops_return_empty"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Return to Service migration test failed. Recent PostgreSQL logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_alembic() {
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Starting disposable PostgreSQL..."
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 60 )); then
|
||||||
|
echo "PostgreSQL did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building migration image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Preparing previous head and truthful legacy completion data..."
|
||||||
|
run_alembic upgrade 0011_repair_timeline
|
||||||
|
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${previous_revision}" == *"0011_repair_timeline"* ]]
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, display_name, password_hash, role_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'00000000-0000-0000-0000-000000001301',
|
||||||
|
'return-migration-test',
|
||||||
|
'Return Migration Test',
|
||||||
|
'not-used',
|
||||||
|
id
|
||||||
|
FROM roles
|
||||||
|
WHERE name = 'Admin';
|
||||||
|
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, asset_tag, status, lifecycle_status, first_seen_at, last_seen_at
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001302',
|
||||||
|
'RETURN-MIGRATION-LEGACY',
|
||||||
|
'unknown',
|
||||||
|
'active',
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO repairs (
|
||||||
|
id, miner_id, status, reason, opened_by, completed_at, completed_by
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001303',
|
||||||
|
'00000000-0000-0000-0000-000000001302',
|
||||||
|
'COMPLETED',
|
||||||
|
'OTHER',
|
||||||
|
'00000000-0000-0000-0000-000000001301',
|
||||||
|
now(),
|
||||||
|
'00000000-0000-0000-0000-000000001301'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Upgrading previous head to Return to Service..."
|
||||||
|
run_alembic upgrade head
|
||||||
|
current_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
|
echo "Checking schema, permission matrix, constraints, and legacy preservation..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_roles TEXT[];
|
||||||
|
v_constraint_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF (
|
||||||
|
SELECT count(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'repairs'
|
||||||
|
AND column_name IN ('completion_summary', 'resulting_lifecycle')
|
||||||
|
) <> 2 THEN
|
||||||
|
RAISE EXCEPTION 'Return to Service columns are missing';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM repairs
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000001303'
|
||||||
|
AND (
|
||||||
|
completion_summary IS NOT NULL
|
||||||
|
OR resulting_lifecycle IS NOT NULL
|
||||||
|
)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Legacy completion metadata was fabricated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1 THEN
|
||||||
|
RAISE EXCEPTION 'repairs.complete permission was not registered once';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT array_agg(roles.name ORDER BY roles.name)
|
||||||
|
INTO v_roles
|
||||||
|
FROM role_permissions
|
||||||
|
JOIN roles ON roles.id = role_permissions.role_id
|
||||||
|
JOIN permissions ON permissions.id = role_permissions.permission_id
|
||||||
|
WHERE permissions.key = 'repairs.complete';
|
||||||
|
IF v_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
|
||||||
|
RAISE EXCEPTION 'Unexpected repairs.complete role matrix: %', v_roles;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*)
|
||||||
|
INTO v_constraint_count
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid IN ('repairs'::regclass, 'repair_events'::regclass)
|
||||||
|
AND conname IN (
|
||||||
|
'ck_repairs_resulting_lifecycle',
|
||||||
|
'ck_repairs_completion_summary',
|
||||||
|
'ck_repairs_completion_metadata_state',
|
||||||
|
'ck_repair_events_type',
|
||||||
|
'ck_repair_events_payload_shape'
|
||||||
|
);
|
||||||
|
IF v_constraint_count <> 5 THEN
|
||||||
|
RAISE EXCEPTION 'Expected Return to Service constraints are missing';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (
|
||||||
|
SELECT convalidated
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'repairs'::regclass
|
||||||
|
AND conname = 'ck_repairs_completion_metadata_state'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Legacy completion compatibility constraint was unexpectedly validated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (
|
||||||
|
miner_id, status, reason, opened_by, completed_at, completed_by
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001302',
|
||||||
|
'COMPLETED',
|
||||||
|
'OTHER',
|
||||||
|
'00000000-0000-0000-0000-000000001301',
|
||||||
|
now(),
|
||||||
|
'00000000-0000-0000-0000-000000001301'
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'New completed repair without resulting lifecycle was accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN check_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES ('repairs.complete', 'Return repaired miners to service')
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET description = EXCLUDED.description;
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.key = 'repairs.complete'
|
||||||
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1
|
||||||
|
OR (
|
||||||
|
SELECT count(*)
|
||||||
|
FROM role_permissions
|
||||||
|
JOIN permissions ON permissions.id = role_permissions.permission_id
|
||||||
|
WHERE permissions.key = 'repairs.complete'
|
||||||
|
) <> 3 THEN
|
||||||
|
RAISE EXCEPTION 'repairs.complete seeding is not idempotent';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Downgrading and re-upgrading without new completion history..."
|
||||||
|
run_alembic downgrade 0011_repair_timeline
|
||||||
|
downgrade_state="$(
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL'
|
||||||
|
SELECT
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM permissions WHERE key = 'repairs.complete'
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM permissions WHERE key = 'repairs.read'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'repairs'
|
||||||
|
AND column_name IN ('completion_summary', 'resulting_lifecycle')
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM repairs
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000001303'
|
||||||
|
AND status = 'COMPLETED'
|
||||||
|
AND completed_at IS NOT NULL
|
||||||
|
AND completed_by IS NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
)"
|
||||||
|
[[ "${downgrade_state}" == "t" ]]
|
||||||
|
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${downgraded_revision}" == *"0011_repair_timeline"* ]]
|
||||||
|
run_alembic upgrade head
|
||||||
|
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${reupgraded_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
|
echo "Checking downgrade refusal after authoritative completion history..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
UPDATE repairs
|
||||||
|
SET completion_summary = 'Migration completion',
|
||||||
|
resulting_lifecycle = 'active'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000001303';
|
||||||
|
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001303',
|
||||||
|
1,
|
||||||
|
'REPAIR_COMPLETED',
|
||||||
|
'00000000-0000-0000-0000-000000001301',
|
||||||
|
'{
|
||||||
|
"previous_miner_lifecycle":"in_repair",
|
||||||
|
"resulting_miner_lifecycle":"active",
|
||||||
|
"completion_summary":"Migration completion"
|
||||||
|
}'::jsonb
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
if downgrade_error="$(run_alembic downgrade 0011_repair_timeline 2>&1)"; then
|
||||||
|
echo "Downgrade discarded Return to Service history." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${downgrade_error}" == *"would lose authoritative history"* ]]
|
||||||
|
failed_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${failed_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
|
echo "Migrating an empty database from base through head..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||||
|
-c "CREATE DATABASE minerops_return_empty"' >/dev/null
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
|
backend alembic upgrade head
|
||||||
|
empty_revision="$(
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
|
backend alembic current 2>/dev/null
|
||||||
|
)"
|
||||||
|
[[ "${empty_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
|
echo "Return to Service migration test passed."
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_RETURN_SERVICE_TEST_PROJECT:-minerops-return-service-$$}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Return to Service tests failed. Recent PostgreSQL logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting disposable Return to Service database..."
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 60 )); then
|
||||||
|
echo "PostgreSQL did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building Return to Service test image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Migrating disposable database..."
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Running Return to Service repository, service, and router tests..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v tests.test_return_to_service
|
||||||
|
|
||||||
|
echo "Return to Service tests passed."
|
||||||
Reference in new issue
Block a user