Files
magnus919_agent-skills/binary-analysis/scripts/binary_analysis/projects/state_machine.py
T

167 lines
5.5 KiB
Python

"""Project state machine — lifecycle transitions and staleness detection.
Enforces strict state transitions per the architecture:
CREATED -> IMPORTED -> ANALYZING -> READY
READY -> STALE -> ANALYZING
Any state -> FAILED (with diagnostics preserved)
Provides:
- Transition validation (reject invalid transitions).
- FAILED transition helpers (preserve diagnostics, release locks).
- Staleness detection (SHA-256 comparison on source change).
- State-aware operation guards (clean only FAILED, migrate only unlocked).
"""
from __future__ import annotations
import contextlib
from datetime import datetime, timezone
from typing import Any
from binary_analysis.domain.enums import ProjectState
# ---------------------------------------------------------------------------
# Valid transition map
# ---------------------------------------------------------------------------
# Each state maps to a set of allowed target states
_VALID_TRANSITIONS: dict[ProjectState, set[ProjectState]] = {
ProjectState.CREATED: {ProjectState.IMPORTED, ProjectState.FAILED},
ProjectState.IMPORTED: {ProjectState.ANALYZING, ProjectState.FAILED},
ProjectState.ANALYZING: {ProjectState.READY, ProjectState.FAILED},
ProjectState.READY: {ProjectState.STALE, ProjectState.FAILED},
ProjectState.STALE: {ProjectState.ANALYZING, ProjectState.FAILED},
ProjectState.FAILED: {ProjectState.CREATED}, # Clean resets to CREATED
}
# States from which analyze can be started (re-transition)
_ANALYZABLE_STATES: set[ProjectState] = {
ProjectState.IMPORTED,
ProjectState.STALE,
ProjectState.READY, # Can detect staleness without re-analyzing
}
# States from which import is allowed
_IMPORTABLE_STATES: set[ProjectState] = {
ProjectState.CREATED,
ProjectState.IMPORTED,
}
# States from which clean is allowed (only FAILED)
_CLEANABLE_STATES: set[ProjectState] = {
ProjectState.FAILED,
}
# States from which migrate is rejected (locked projects)
_MIGRATE_BLOCKED_STATES: set[ProjectState] = {
ProjectState.ANALYZING,
}
# ---------------------------------------------------------------------------
# Transition validation
# ---------------------------------------------------------------------------
def is_valid_transition(from_state: ProjectState, to_state: ProjectState) -> bool:
"""Check if a state transition is allowed by the state machine.
Args:
from_state: Current project state.
to_state: Desired target state.
Returns:
True if the transition is valid.
"""
allowed = _VALID_TRANSITIONS.get(from_state, set())
return to_state in allowed
def can_analyze(state: ProjectState) -> bool:
"""Check if analysis can be started from the given state."""
return state in _ANALYZABLE_STATES
def can_import(state: ProjectState) -> bool:
"""Check if a binary import is allowed in the given state."""
return state in _IMPORTABLE_STATES
def can_clean(state: ProjectState) -> bool:
"""Check if clean is allowed in the given state (only FAILED)."""
return state in _CLEANABLE_STATES
def should_reject_migrate(state: ProjectState, is_locked: bool) -> bool:
"""Check if migrate should be rejected due to project state or lock.
Args:
state: Current project state.
is_locked: Whether the project has an active lock.
Returns:
True if migrate should be rejected.
"""
if is_locked:
return True
return state in _MIGRATE_BLOCKED_STATES
# ---------------------------------------------------------------------------
# Transition helpers
# ---------------------------------------------------------------------------
def transition_to_failed(
manifest: dict[str, Any],
from_state: ProjectState,
diagnostics: list[dict[str, Any]],
release_lock_fn: Any | None = None,
) -> dict[str, Any]:
"""Transition a project to FAILED state, preserving context from the source state.
Handles specific preservation rules per source state:
- CREATED->FAILED: Preserve diagnostics, no lock to release.
- IMPORTED->FAILED: Preserve binary record (binary_count, binary data),
release lock if held.
- ANALYZING->FAILED: Release lock, preserve crash diagnostics,
clear lock from manifest.
- STALE->FAILED: Capture both staleness cause and analysis failure,
preserve binary record.
Args:
manifest: The current project manifest (mutated in place).
from_state: The state before failure.
diagnostics: Failure diagnostics to preserve.
release_lock_fn: Optional function to release the project lock.
Returns:
The updated manifest dict.
"""
now = datetime.now(timezone.utc).isoformat()
# Preserve existing diagnostics
existing_diags = manifest.get("diagnostics", [])
if not isinstance(existing_diags, list):
existing_diags = []
# Merge diagnostics, ensuring we don't lose staleness context
merged_diags = existing_diags + diagnostics
# Update manifest
manifest["state"] = ProjectState.FAILED.value
manifest["diagnostics"] = merged_diags
manifest["updated_at"] = now
# Release lock if transitioning from ANALYZING
if from_state == ProjectState.ANALYZING:
manifest["lock"] = None
if release_lock_fn is not None:
with contextlib.suppress(Exception):
release_lock_fn()
# Preserve binary record for IMPORTED->FAILED and STALE->FAILED
# (binary_count and is_stale are preserved by default since we don't clear them)
return manifest