mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
167 lines
5.5 KiB
Python
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
|