Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
56e46bc
fix: FIT-1078: set task state correctly on import
matt-bernstein Dec 10, 2025
6a12d78
remove unused arg
matt-bernstein Dec 10, 2025
94c2274
move storage fsm tests to lso
matt-bernstein Dec 10, 2025
da63d6d
fix import
matt-bernstein Dec 10, 2025
7d61b03
Apply pre-commit linters
matt-bernstein Dec 10, 2025
3ef6101
import
matt-bernstein Dec 10, 2025
1a15c70
tests fail properly
matt-bernstein Dec 10, 2025
94bfb95
Apply pre-commit linters
matt-bernstein Dec 10, 2025
402dc87
Merge branch 'develop' into 'fb-FIT-1078-2/task-import-state'
matt-bernstein Dec 11, 2025
dcb548c
stub state inference in LSO
matt-bernstein Dec 11, 2025
6997555
fix test
matt-bernstein Dec 11, 2025
446d295
Merge branch 'develop' into 'fb-FIT-1078-2/task-import-state'
matt-bernstein Dec 11, 2025
ba8ccfd
Apply pre-commit linters
matt-bernstein Dec 11, 2025
f0a5596
use new backfill for storages
matt-bernstein Dec 11, 2025
6e1d64a
Merge remote-tracking branch 'origin/develop' into fb-FIT-1078-2/task…
matt-bernstein Dec 12, 2025
727fe8a
fix settings switch
matt-bernstein Dec 12, 2025
098ce9b
remove entity_type arg
matt-bernstein Dec 12, 2025
204580b
swap out inference func
matt-bernstein Dec 12, 2025
4dc9dd5
remove unused util
matt-bernstein Dec 15, 2025
fe3f76a
remove unused arguments
matt-bernstein Dec 15, 2025
0fd8b5b
rename lso function to match lse
matt-bernstein Dec 15, 2025
3cb5cd4
Merge remote-tracking branch 'origin/develop' into fb-FIT-1078-2/task…
matt-bernstein Dec 15, 2025
2c8e177
remove unused tests
matt-bernstein Dec 15, 2025
3dc4e47
Revert "remove unused tests"
matt-bernstein Dec 15, 2025
6c39f8a
re-add util
matt-bernstein Dec 15, 2025
4558abc
replace backfill function
matt-bernstein Dec 15, 2025
2f37133
Merge remote-tracking branch 'origin/develop' into fb-FIT-1078-2/task…
matt-bernstein Dec 15, 2025
1ba391d
use same FSM init for bulk import as for import storage
matt-bernstein Dec 15, 2025
ac7d2bb
add comment
matt-bernstein Dec 15, 2025
9de300f
add test
matt-bernstein Dec 15, 2025
ba684fa
skip
matt-bernstein Dec 15, 2025
e9966e0
Merge remote-tracking branch 'origin/develop' into fb-FIT-1078-2/task…
matt-bernstein Dec 15, 2025
8c59d0d
Merge branch 'develop' into 'fb-FIT-1078-2/task-import-state'
matt-bernstein Dec 16, 2025
83ae652
Merge branch 'develop' into 'fb-FIT-1078-2/task-import-state'
matt-bernstein Dec 16, 2025
a338e75
Merge branch 'develop' into 'fb-FIT-1078-2/task-import-state'
matt-bernstein Dec 17, 2025
5cb78a1
add back get_or_initialize params
matt-bernstein Dec 17, 2025
f4b48b3
move function from utils to state_inference
matt-bernstein Dec 17, 2025
29fc195
helpers note
matt-bernstein Dec 17, 2025
8308355
Merge remote-tracking branch 'origin/develop' into fb-FIT-1078-2/task…
matt-bernstein Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions label_studio/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,7 @@ def collect_versions_dummy(**kwargs):
# Base FSM (Finite State Machine) Configuration for Label Studio
FSM_CACHE_TTL = 300 # Cache TTL in seconds (5 minutes)
FSM_SYNC_PROJECT_STATE = 'fsm.project_transitions.sync_project_state'
FSM_INFERENCE_FUNCTION = 'fsm.state_inference._get_or_infer_state'

# Used for async migrations. In LSE this is set to a real queue name, including here so we
# can use settings.SERVICE_QUEUE_NAME in async migrations in LSO
Expand Down
3 changes: 2 additions & 1 deletion label_studio/fsm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,5 @@ When contributing:
- Keep framework code generic and reusable
- Add performance tests for UUID7 optimizations
- Document extension points and customization options
- Ensure extensibility is preserved
- Ensure extensibility is preserved
- When adding pytests, use and extend reusable helper functions in `fsm/tests/helpers.py` when appropriate
13 changes: 7 additions & 6 deletions label_studio/fsm/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
This module contains reusable functions for FSM state management that are
used across different parts of the codebase.
"""

import logging

from core.current_request import CurrentContext
from fsm.state_inference import get_or_infer_state
from fsm.utils import get_or_initialize_state

logger = logging.getLogger(__name__)


Expand All @@ -32,7 +35,6 @@ def backfill_fsm_states_for_tasks(storage_id, tasks_created, link_class):
return

try:
from lse_fsm.state_inference import backfill_state_for_entity
from tasks.models import Task

# Get tasks created in this sync
Expand All @@ -48,12 +50,11 @@ def backfill_fsm_states_for_tasks(storage_id, tasks_created, link_class):

# Backfill initial CREATED state for each task
for task in tasks:
backfill_state_for_entity(task, 'task', create_record=True)

inferred_state = get_or_infer_state(task)
get_or_initialize_state(task, user=CurrentContext.get_user(), inferred_state=inferred_state)

logger.info(f'Storage sync: FSM states created for {len(task_ids)} tasks')
except ImportError:
# LSE not available (OSS), skip FSM sync
logger.debug('LSE not available, skipping FSM state backfill for storage sync')
except Exception as e:
# Don't fail storage sync if FSM sync fails
logger.error(f'FSM sync after storage sync failed: {e}', exc_info=True)
Expand Down
5 changes: 3 additions & 2 deletions label_studio/fsm/project_transitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
from django.conf import settings
from fsm.registry import register_state_transition
from fsm.state_choices import ProjectStateChoices
from fsm.state_inference import get_or_infer_state
from fsm.state_manager import StateManager
from fsm.transitions import ModelChangeTransition, TransitionContext
from fsm.utils import get_or_initialize_state, infer_entity_state_from_data
from fsm.utils import get_or_initialize_state


@register_state_transition('project', 'project_created', triggers_on_create=True, triggers_on_update=False)
Expand Down Expand Up @@ -135,7 +136,7 @@ def transition(self, context: TransitionContext) -> Dict[str, Any]:

def sync_project_state(project, user=None, reason=None, context_data=None):
current_state = StateManager.get_current_state_value(project)
inferred_state = infer_entity_state_from_data(project)
inferred_state = get_or_infer_state(project)

if current_state is None:
get_or_initialize_state(project, user=user, inferred_state=inferred_state)
Expand Down
77 changes: 77 additions & 0 deletions label_studio/fsm/state_inference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import logging
from typing import Optional

from core.utils.common import load_func
from django.conf import settings

logger = logging.getLogger(__name__)


def _get_or_infer_state(entity) -> Optional[str]:
"""
Infer what the FSM state should be based on entity's current data.

This is used for "cold start" scenarios where entities exist in the database
but don't have FSM state records yet (e.g., after FSM deployment to production
with pre-existing data).

Args:
entity: The entity to infer state for (Task, Project, or Annotation)

Returns:
Inferred state value, or None if entity type not supported

Examples:
>>> task = Task.objects.get(id=123)
>>> task.is_labeled = True
>>> _get_or_infer_state(task)
'COMPLETED'

>>> project = Project.objects.get(id=456)
>>> _get_or_infer_state(project)
'CREATED'
"""
from fsm.state_choices import AnnotationStateChoices, ProjectStateChoices, TaskStateChoices

entity_type = entity._meta.model_name.lower()

if entity_type == 'task':
# Task state depends on whether it has been labeled
return TaskStateChoices.COMPLETED if entity.is_labeled else TaskStateChoices.CREATED
elif entity_type == 'project':
# Project state depends on task completion
# If no tasks exist, project is CREATED
# If any tasks are completed, project is at least IN_PROGRESS
# If all tasks are completed, project is COMPLETED
tasks = entity.tasks.all()
if not tasks.exists():
return ProjectStateChoices.CREATED

# Count labeled tasks to determine project state
total_tasks = tasks.count()
labeled_tasks = tasks.filter(is_labeled=True).count()

if labeled_tasks == 0:
return ProjectStateChoices.CREATED
elif labeled_tasks == total_tasks:
return ProjectStateChoices.COMPLETED
else:
return ProjectStateChoices.IN_PROGRESS
elif entity_type == 'annotation':
# Annotations are SUBMITTED when created
return AnnotationStateChoices.SUBMITTED
else:
logger.warning(
f'Cannot infer state for unknown entity type: {entity_type}',
extra={
'event': 'fsm.infer_state_unknown_type',
'entity_type': entity_type,
'entity_id': entity.pk,
},
)
return None


def get_or_infer_state(entity) -> Optional[str]:
func = load_func(settings.FSM_INFERENCE_FUNCTION)
return func(entity)
16 changes: 16 additions & 0 deletions label_studio/fsm/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,25 @@
from fsm.registry import state_choices_registry, state_model_registry, transition_registry
from fsm.state_manager import StateManager

from label_studio.tests import conftest as ls_tests_conftest

logger = logging.getLogger(__name__)


# Re-export core fixtures from main LS test suite so FSM tests behave like OSS tests.
# NOTE: We alias the same underlying functions so pytest registers them as fixtures
# in this module as well (including their autouse behavior).
django_live_url = ls_tests_conftest.get_server_url
business_client = ls_tests_conftest.business_client

# Storage-related fixtures to mock cloud providers for import storages
aws_credentials = ls_tests_conftest.aws_credentials
s3 = ls_tests_conftest.s3
s3_with_images = ls_tests_conftest.s3_with_images
gcs_client = ls_tests_conftest.gcs_client
azure_client = ls_tests_conftest.azure_client


@pytest.fixture(autouse=True, scope='function')
def fsm_test_isolation():
"""
Expand Down
Loading
Loading