diff --git a/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e9_align_ops_audit_constraints.py b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e9_align_ops_audit_constraints.py new file mode 100644 index 0000000..7ecbadc --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e9_align_ops_audit_constraints.py @@ -0,0 +1,52 @@ +"""align ops audit constraints and reason_code length + +Revision ID: f1a2b3c4d5e9 +Revises: f1a2b3c4d5e8 +Create Date: 2026-09-21 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e9' +down_revision = 'f1a2b3c4d5e8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Update audit_result check constraint to include 'failed' + op.drop_constraint("audit_result", "audit_events", schema="ops") + op.create_check_constraint( + "audit_result", + "audit_events", + "result IN ('success', 'denied', 'failed')", + schema="ops", + ) + + # 2. Expand reason_code to VARCHAR(64) to match models and prevent truncation + op.alter_column( + "audit_events", + "reason_code", + type_=sa.String(length=64), + existing_type=sa.String(length=32), + schema="ops", + ) + + +def downgrade() -> None: + op.alter_column( + "audit_events", + "reason_code", + type_=sa.String(length=32), + existing_type=sa.String(length=64), + schema="ops", + ) + op.drop_constraint("audit_result", "audit_events", schema="ops") + op.create_check_constraint( + "audit_result", + "audit_events", + "result IN ('success', 'denied')", + schema="ops", + ) diff --git a/backend/motorsignal_api/app/main.py b/backend/motorsignal_api/app/main.py index 3a5a18a..c91f552 100644 --- a/backend/motorsignal_api/app/main.py +++ b/backend/motorsignal_api/app/main.py @@ -2882,6 +2882,7 @@ def ops_create_action( actor_identity=operator.identity, actor_role=operator.role, target_resource=f"action:{action.id}", + reason_code=(action.failure_code or "")[:64] if action.state != "succeeded" else None, result="success" if action.state == "succeeded" else "failed", metadata={ "action_type": action.action_type, diff --git a/backend/motorsignal_api/app/ops_actions_service.py b/backend/motorsignal_api/app/ops_actions_service.py index ef78bf9..dcf3823 100644 --- a/backend/motorsignal_api/app/ops_actions_service.py +++ b/backend/motorsignal_api/app/ops_actions_service.py @@ -197,11 +197,19 @@ def reconcile_stale_actions(db: DbSession) -> int: for act in stale_actions: if act.action_type in ("restart_api", "reboot_server"): - # The API is currently running and processing requests, which confirms the restart/reboot completed. - act.state = "succeeded" - act.completion_timestamp = now - act.status_message = f"Reconciled upon system startup; {act.action_type} verified operational." - count += 1 + # Only actions that were actively in-flight (running/verifying) completed across restart. + # Actions merely queued or in preflight never executed before the system restarted. + if act.state in ("running", "verifying"): + act.state = "succeeded" + act.completion_timestamp = now + act.status_message = f"Reconciled upon system startup; {act.action_type} verified operational." + count += 1 + else: + act.state = "failed" + act.completion_timestamp = now + act.failure_code = "interrupted_before_execution" + act.status_message = f"Action was interrupted before execution by a system restart." + count += 1 else: # Other actions left running when process terminated were interrupted act.state = "failed" diff --git a/backend/motorsignal_api/tests/test_ops_actions.py b/backend/motorsignal_api/tests/test_ops_actions.py index afd8da0..82d4496 100644 --- a/backend/motorsignal_api/tests/test_ops_actions.py +++ b/backend/motorsignal_api/tests/test_ops_actions.py @@ -480,6 +480,21 @@ class TestOpsActions(unittest.TestCase): self.assertEqual(act_deploy.state, "failed") self.assertEqual(act_deploy.failure_code, "interrupted_by_restart") + def test_reconciliation_queued_action_fails_on_restart(self): + import ops_actions_service + with DbSession(self.engine) as db: + # Action that was merely queued should NOT report succeeded + act_queued_reboot = OpsAction(id=uuid.uuid4(), action_type="reboot_server", requested_by_id=self.op_id, state="queued") + db.add(act_queued_reboot) + db.commit() + + reconciled = ops_actions_service.reconcile_stale_actions(db) + self.assertEqual(reconciled, 1) + + db.refresh(act_queued_reboot) + self.assertEqual(act_queued_reboot.state, "failed") + self.assertEqual(act_queued_reboot.failure_code, "interrupted_before_execution") + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/website/app/api/ops/actions/route.ts b/website/app/api/ops/actions/route.ts index b721a4a..478110b 100644 --- a/website/app/api/ops/actions/route.ts +++ b/website/app/api/ops/actions/route.ts @@ -63,6 +63,9 @@ export async function GET(request: NextRequest) { } const data = await response.json(); + if (Array.isArray(data)) { + return NextResponse.json({ actions: data, total: data.length }); + } return NextResponse.json(data); } catch { return NextResponse.json( diff --git a/website/components/ops/operations-actions.tsx b/website/components/ops/operations-actions.tsx index 929e6c4..ffd4cb9 100644 --- a/website/components/ops/operations-actions.tsx +++ b/website/components/ops/operations-actions.tsx @@ -135,8 +135,12 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions cache: 'no-store', }); if (resp.ok) { - const data: OpsActionsResponse = await resp.json(); - const actionList = data.actions || []; + const data = await resp.json(); + const actionList: OpsAction[] = Array.isArray(data) + ? data + : Array.isArray((data as OpsActionsResponse)?.actions) + ? (data as OpsActionsResponse).actions + : []; setActions(actionList); setHistoryError(null);