Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"),
)

# Create app_state table (depends on projects)
op.create_table(
"app_state",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("last_used_project_id", sa.String(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.ForeignKeyConstraint(["last_used_project_id"], ["projects.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)

# Create pipelines table (depends on projects, sources, sinks, models)
op.create_table(
"pipelines",
Expand All @@ -153,6 +163,7 @@ def downgrade() -> None:
"""Downgrade schema - drop all tables in reverse order."""
# Drop tables in reverse order of dependencies
op.drop_table("pipelines")
op.drop_table("app_state")
op.drop_table("models")
op.drop_table("dataset_snapshot")
op.drop_table("media")
Expand Down
2 changes: 2 additions & 0 deletions application/backend/src/api/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
get_pipeline_metrics_service,
get_pipeline_service,
get_project_id,
get_project_selection_service,
get_project_service,
get_scheduler,
get_sink_id,
Expand All @@ -41,6 +42,7 @@
"get_pipeline_metrics_service",
"get_pipeline_service",
"get_project_id",
"get_project_selection_service",
"get_project_service",
"get_scheduler",
"get_sink_id",
Expand Down
6 changes: 6 additions & 0 deletions application/backend/src/api/dependencies/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
JobService,
MediaService,
PipelineService,
ProjectSelectionService,
ProjectService,
)
from services.metrics_service import MetricsService
Expand All @@ -27,6 +28,11 @@ async def get_project_service() -> ProjectService:
return ProjectService()


async def get_project_selection_service() -> ProjectSelectionService:
"""Provides a ProjectSelectionService."""
return ProjectSelectionService()


async def get_job_service() -> JobService:
"""Provides a JobService"""
return JobService()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

from typing import Annotated

from fastapi import APIRouter, Body, Depends, HTTPException, status

from api.dependencies import get_project_selection_service
from api.endpoints import API_PREFIX
from pydantic_models import LastUsedProjectUpdate, StartupProjectSelection
from services import ProjectSelectionService, ResourceNotFoundError

router = APIRouter(prefix=f"{API_PREFIX}/projects", tags=["Project"])


@router.get("/startup-selection")
async def get_startup_project_selection(
project_selection_service: Annotated[ProjectSelectionService, Depends(get_project_selection_service)],
) -> StartupProjectSelection:
"""Return the project that should be restored when the app starts."""
return await project_selection_service.get_startup_project_selection()


@router.put(
"/last-used",
status_code=status.HTTP_204_NO_CONTENT,
responses={
status.HTTP_204_NO_CONTENT: {"description": "Last used project stored successfully"},
status.HTTP_404_NOT_FOUND: {"description": "Project not found"},
},
)
async def update_last_used_project(
project_selection: Annotated[LastUsedProjectUpdate, Body()],
project_selection_service: Annotated[ProjectSelectionService, Depends(get_project_selection_service)],
) -> None:
"""Persist the project that should be restored on the next application start."""
try:
await project_selection_service.set_last_used_project(project_selection.project_id)
except ResourceNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.message) from error
15 changes: 15 additions & 0 deletions application/backend/src/db/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ class ProjectDB(Base):
pipeline = relationship("PipelineDB", back_populates="project", uselist=False, lazy="joined")


class AppStateDB(Base):
__tablename__ = "app_state"

id: Mapped[int] = mapped_column(primary_key=True, default=1)
last_used_project_id: Mapped[str | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL"),
nullable=True,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
)


class MediaDB(Base):
__tablename__ = "media"

Expand Down
2 changes: 2 additions & 0 deletions application/backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from api.endpoints.model_endpoints import model_router
from api.endpoints.pipeline_endpoints import router as pipeline_router
from api.endpoints.project_endpoints import project_router
from api.endpoints.project_selection_endpoints import router as project_selection_router
from api.endpoints.sink_endpoints import router as sink_router
from api.endpoints.snapshot_endpoints import router as snapshot_router
from api.endpoints.source_endpoints import router as source_router
Expand Down Expand Up @@ -56,6 +57,7 @@
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(project_selection_router)
app.include_router(project_router)
app.include_router(job_router)
app.include_router(media_router)
Expand Down
4 changes: 4 additions & 0 deletions application/backend/src/pydantic_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .model import Model, ModelList, PredictionLabel, PredictionResponse
from .pipeline import Pipeline, PipelineStatus
from .project import Project, ProjectList, ProjectUpdate
from .project_selection import LastUsedProjectUpdate, StartupProjectSelection, StartupProjectSelectionSource
from .sink import DisconnectedSinkConfig, OutputFormat, Sink, SinkType
from .source import DisconnectedSourceConfig, Source, SourceType
from .system import LibraryVersions, SystemInfo
Expand All @@ -24,6 +25,7 @@
"JobList",
"JobStatus",
"JobType",
"LastUsedProjectUpdate",
"LatencyMetrics",
"LibraryVersions",
"Media",
Expand All @@ -44,6 +46,8 @@
"SinkType",
"Source",
"SourceType",
"StartupProjectSelection",
"StartupProjectSelectionSource",
"SystemInfo",
"TimeWindow",
"TrainableModel",
Expand Down
24 changes: 24 additions & 0 deletions application/backend/src/pydantic_models/project_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

from enum import StrEnum

from pydantic import BaseModel

from utils.short_uuid import ShortUUID


class StartupProjectSelectionSource(StrEnum):
LAST_USED = "last_used"
ACTIVE_PIPELINE = "active_pipeline"
FIRST_PROJECT = "first_project"
NONE = "none"


class StartupProjectSelection(BaseModel):
project_id: ShortUUID | None = None
source: StartupProjectSelectionSource


class LastUsedProjectUpdate(BaseModel):
project_id: ShortUUID
4 changes: 3 additions & 1 deletion application/backend/src/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (C) 2025 Intel Corporation
# Copyright (C) 2025-2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

from .app_state_repo import AppStateRepository
from .dataset_snapshot_repo import DatasetSnapshotRepository
from .job_repo import JobRepository
from .media_repo import MediaRepository
Expand All @@ -11,6 +12,7 @@
from .source_repo import SourceRepository

__all__ = [
"AppStateRepository",
"DatasetSnapshotRepository",
"JobRepository",
"MediaRepository",
Expand Down
40 changes: 40 additions & 0 deletions application/backend/src/repositories/app_state_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

import sqlalchemy as sa
from sqlalchemy.ext.asyncio.session import AsyncSession

from db.schema import AppStateDB

APP_STATE_ID = 1


class AppStateRepository:
"""Repository for persisted application-wide state."""

def __init__(self, db: AsyncSession):
self.db = db

async def get_last_used_project_id(self) -> str | None:
result = await self.db.execute(
sa.select(AppStateDB.last_used_project_id).where(AppStateDB.id == APP_STATE_ID),
)
return result.scalar_one_or_none()

async def set_last_used_project_id(self, project_id: str) -> None:
state = await self.db.get(AppStateDB, APP_STATE_ID)

if state is None:
self.db.add(AppStateDB(id=APP_STATE_ID, last_used_project_id=project_id))
else:
state.last_used_project_id = project_id

await self.db.commit()

async def clear_last_used_project_id(self) -> None:
state = await self.db.get(AppStateDB, APP_STATE_ID)
if state is None:
return

state.last_used_project_id = None
await self.db.commit()
11 changes: 11 additions & 0 deletions application/backend/src/repositories/project_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,14 @@ async def get_dataset_timestamp(self, project_id: str | ShortUUID) -> datetime:
"""Get the dataset_updated_at timestamp for the given project."""
result = await self.db.execute(sa.select(self.schema.dataset_updated_at).where(ProjectDB.id == str(project_id)))
return result.scalar_one()

async def get_first_project(self) -> Project | None:
"""Get the first project in a deterministic order.

Projects are ordered by creation time, then by ID as a stable tie-breaker.
"""
result = await self.db.execute(
sa.select(ProjectDB).order_by(ProjectDB.created_at.asc(), ProjectDB.id.asc()).limit(1),
)
first_project = result.scalars().first()
return self.from_schema(first_project) if first_project else None
2 changes: 2 additions & 0 deletions application/backend/src/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .media_service import MediaService
from .model_service import ModelService
from .pipeline_service import PipelineService
from .project_selection_service import ProjectSelectionService
from .project_service import ProjectService
from .training_service import TrainingService
from .video_service import VideoService
Expand All @@ -30,6 +31,7 @@
"MediaService",
"ModelService",
"PipelineService",
"ProjectSelectionService",
"ProjectService",
"ResourceAlreadyExistsError",
"ResourceInUseError",
Expand Down
87 changes: 87 additions & 0 deletions application/backend/src/services/project_selection_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0


from db import get_async_db_session_ctx
from pydantic_models import StartupProjectSelection, StartupProjectSelectionSource
from repositories import AppStateRepository, PipelineRepository, ProjectRepository
from services.exceptions import ResourceNotFoundError, ResourceType
from utils.short_uuid import ShortUUID


async def _resolve_last_used(
app_state_repo: AppStateRepository,
project_repo: ProjectRepository,
) -> StartupProjectSelection | None:
last_used_project_id = await app_state_repo.get_last_used_project_id()
if last_used_project_id is None:
return None

last_used_project = await project_repo.get_by_id(last_used_project_id)
if last_used_project is not None:
return StartupProjectSelection(
project_id=last_used_project.id,
source=StartupProjectSelectionSource.LAST_USED,
)

await app_state_repo.clear_last_used_project_id()
return None


async def _resolve_active_pipeline(pipeline_repo: PipelineRepository) -> StartupProjectSelection | None:
active_pipeline = await pipeline_repo.get_active_pipeline()
if active_pipeline is None:
return None
return StartupProjectSelection(
project_id=active_pipeline.project_id,
source=StartupProjectSelectionSource.ACTIVE_PIPELINE,
)


async def _resolve_first_project(project_repo: ProjectRepository) -> StartupProjectSelection | None:
first_project = await project_repo.get_first_project()
if first_project is None:
return None
return StartupProjectSelection(
project_id=first_project.id,
source=StartupProjectSelectionSource.FIRST_PROJECT,
)


class ProjectSelectionService:
@staticmethod
async def get_startup_project_selection() -> StartupProjectSelection:
"""Resolve startup project in a deterministic order.

Priority:
1. last used project
2. project that owns the active pipeline
3. first project in the existing project list
"""
async with get_async_db_session_ctx() as session:
app_state_repo = AppStateRepository(session)
project_repo = ProjectRepository(session)
pipeline_repo = PipelineRepository(session)

selection = await _resolve_last_used(app_state_repo, project_repo)

if selection is None:
selection = await _resolve_active_pipeline(pipeline_repo)

if selection is None:
selection = await _resolve_first_project(project_repo)

if selection is None:
selection = StartupProjectSelection(source=StartupProjectSelectionSource.NONE)

return selection

@staticmethod
async def set_last_used_project(project_id: ShortUUID) -> None:
async with get_async_db_session_ctx() as session:
project_repo = ProjectRepository(session)
if await project_repo.get_by_id(project_id) is None:
raise ResourceNotFoundError(resource_type=ResourceType.PROJECT, resource_id=str(project_id))
Comment thread
ashwinvaidya17 marked this conversation as resolved.

app_state_repo = AppStateRepository(session)
await app_state_repo.set_last_used_project_id(str(project_id))
Loading
Loading