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
34 changes: 24 additions & 10 deletions server/app/domains/chat/api/history_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,15 @@
from fastapi_pagination.ext.sqlmodel import paginate
from loguru import logger
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, case, delete, desc, func, select
from sqlmodel import Session, case, desc, func, select
from fastapi_babel import _

from app.core.database import session
from app.domains.space.service import SpaceService
from app.domains.space.service.deletion_service import SpaceDeletionService
from app.model.chat.chat_history import ChatHistory, ChatHistoryIn, ChatHistoryOut, ChatHistoryUpdate, ChatStatus
from app.model.project import Project
from app.model.chat.chat_history_grouped import GroupedHistoryResponse, ProjectGroup
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.model.user.key import Key
from app.shared.auth import auth_must
from app.shared.auth.user_auth import V1UserAuth
Expand Down Expand Up @@ -171,11 +170,24 @@ def delete_chat_history(history_id: int, db_session: Session = Depends(session),
raise HTTPException(status_code=403, detail="You are not allowed to delete this chat history")

project_id = history.project_id if history.project_id else history.task_id
canonical_user_id = SpaceService.canonical_user_id(auth.id)

# Project deletion fans history deletes out in parallel. Serialize them on
# the durable Project row so exactly one request observes the final history.
db_session.exec(
select(Project)
.where(
Project.id == project_id,
Project.user_id == canonical_user_id,
)
.with_for_update()
).first()

sibling_count = (
db_session.exec(
select(func.count(ChatHistory.id)).where(
ChatHistory.id != history_id,
ChatHistory.user_id == auth.id,
ChatHistory.project_id == project_id if history.project_id else ChatHistory.task_id == project_id,
)
).first()
Expand All @@ -185,13 +197,15 @@ def delete_chat_history(history_id: int, db_session: Session = Depends(session),
db_session.delete(history)

if sibling_count == 0:
triggers = db_session.exec(select(Trigger).where(Trigger.project_id == project_id)).all()
for trigger in triggers:
db_session.exec(delete(TriggerExecution).where(TriggerExecution.trigger_id == trigger.id))
db_session.delete(trigger)
logger.info(
"Deleted triggers for removed project", extra={"project_id": project_id, "trigger_count": len(triggers)}
SpaceDeletionService.delete_project(
project_id,
auth.id,
db_session,
delete_histories=False,
missing_ok=True,
commit=False,
)
logger.info("Removed durable Project after deleting its final history", extra={"project_id": project_id})

db_session.commit()
return Response(status_code=204)
Expand Down Expand Up @@ -252,4 +266,4 @@ def update_project_name(
except Exception as e:
db_session.rollback()
logger.error("Project name update failed", extra={"user_id": user_id, "project_id": project_id, "error": str(e)})
raise HTTPException(status_code=500, detail="Internal server error")
raise HTTPException(status_code=500, detail="Internal server error")
37 changes: 25 additions & 12 deletions server/app/domains/space/api/space_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@

from app.core.database import session
from app.domains.space.service.apply_service import SpaceApplyService
from app.domains.space.service.deletion_service import SpaceDeletionService
from app.domains.space.service.overlay_service import (
PendingOverlayError,
SpaceOverlayService,
)
from app.domains.space.service.space_service import SpaceHasProjectsError, SpaceService
from app.domains.space.service.space_service import SpaceService
from app.model.project import ProjectIn, ProjectOut, ProjectUpdate
from app.model.space import (
SpaceIn,
Expand Down Expand Up @@ -99,17 +100,7 @@ def delete_space(
auth: V1UserAuth = Depends(auth_must),
):
try:
SpaceService.delete_space(space_id, auth.id, db_session)
except SpaceHasProjectsError as exc:
raise HTTPException(
status_code=409,
detail={
"code": "space_has_projects",
"message": str(exc),
"project_count": exc.project_count,
"projects": exc.projects,
},
) from exc
SpaceDeletionService.delete_space(space_id, auth.id, db_session)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc

Expand Down Expand Up @@ -214,6 +205,28 @@ def update_space_project(
raise HTTPException(status_code=404, detail=str(exc)) from exc


@router.delete(
"/{space_id}/projects/{project_id}",
name="delete space project",
status_code=204,
)
def delete_space_project(
space_id: str,
project_id: str,
db_session: Session = Depends(session),
auth: V1UserAuth = Depends(auth_must),
):
try:
SpaceDeletionService.delete_project(
project_id,
auth.id,
db_session,
space_id=space_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc


@router.post("/{space_id}/projects/{project_id}/promote", name="promote project to folder space", response_model=ProjectOut)
def promote_space_project(
space_id: str,
Expand Down
188 changes: 188 additions & 0 deletions server/app/domains/space/service/deletion_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

"""Destructive deletion helpers for Spaces and Projects.

The normal Space/Project service intentionally supports archival, but the UI's
Delete actions promise permanent removal. These helpers implement that
contract in one transaction and clean the dependent rows that otherwise keep
Project/Space records alive.
"""

from sqlalchemy import and_, or_
from sqlmodel import Session, delete, select

from app.domains.space.service.space_service import SpaceService
from app.model.chat.chat_history import ChatHistory
from app.model.memory import ProjectMemory, SpaceMemory
from app.model.project import Project
from app.model.space import SpaceFileIndex, SpaceFileIndexOverlay
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution


class SpaceDeletionService:
"""Hard-delete Projects and Spaces together with their dependent rows."""

@staticmethod
def _history_user_id(user_id: int | str) -> int | str:
# ChatHistory.user_id is still an integer for the normal auth path,
# while Space/Project ownership is stored canonically as a string.
return int(user_id) if str(user_id).isdigit() else user_id

@staticmethod
def _delete_triggers(
*,
user_id: str,
db_session: Session,
project_id: str | None = None,
space_id: str | None = None,
) -> None:
stmt = select(Trigger).where(Trigger.user_id == user_id)
if project_id is not None:
stmt = stmt.where(Trigger.project_id == project_id)
if space_id is not None:
stmt = stmt.where(Trigger.space_id == space_id)

triggers = db_session.exec(stmt).all()
for trigger in triggers:
db_session.exec(
delete(TriggerExecution).where(
TriggerExecution.trigger_id == trigger.id
)
)
db_session.delete(trigger)

@staticmethod
def delete_project(
project_id: str,
user_id: int | str,
db_session: Session,
*,
space_id: str | None = None,
delete_histories: bool = True,
missing_ok: bool = False,
commit: bool = True,
) -> bool:
"""Permanently delete one owned Project and its server-side data.

Returns ``True`` when a Project row existed. ``missing_ok`` is used by
legacy history cleanup, where old history rows may pre-date the durable
Project table but their triggers/overlays should still be removed.
"""

canonical_user_id = SpaceService.canonical_user_id(user_id)
history_user_id = SpaceDeletionService._history_user_id(user_id)

stmt = select(Project).where(
Project.id == project_id,
Project.user_id == canonical_user_id,
)
if space_id is not None:
stmt = stmt.where(Project.space_id == space_id)
project = db_session.exec(stmt).first()

if project is None and not missing_ok:
raise ValueError("Project not found")

if delete_histories:
db_session.exec(
delete(ChatHistory).where(
ChatHistory.user_id == history_user_id,
or_(
ChatHistory.project_id == project_id,
and_(
ChatHistory.project_id.is_(None),
ChatHistory.task_id == project_id,
),
),
)
)

SpaceDeletionService._delete_triggers(
user_id=canonical_user_id,
project_id=project_id,
db_session=db_session,
)
db_session.exec(
delete(ProjectMemory).where(ProjectMemory.project_id == project_id)
)
db_session.exec(
delete(SpaceFileIndexOverlay).where(
SpaceFileIndexOverlay.project_id == project_id
)
)

if project is not None:
db_session.delete(project)

if commit:
db_session.commit()
return project is not None

@staticmethod
def delete_space(
space_id: str,
user_id: int | str,
db_session: Session,
) -> None:
"""Permanently delete an owned Space and every Project it contains."""

canonical_user_id = SpaceService.canonical_user_id(user_id)
history_user_id = SpaceDeletionService._history_user_id(user_id)
space = SpaceService.get_space(space_id, canonical_user_id, db_session)

projects = db_session.exec(
select(Project).where(
Project.user_id == canonical_user_id,
Project.space_id == space_id,
)
).all()
for project in projects:
SpaceDeletionService.delete_project(
project.id,
user_id,
db_session,
space_id=space_id,
commit=False,
)

# Clean legacy/orphan rows that are keyed directly by Space and may
# have been created before the durable Project model was introduced.
db_session.exec(
delete(ChatHistory).where(
ChatHistory.user_id == history_user_id,
ChatHistory.space_id == space_id,
)
)
SpaceDeletionService._delete_triggers(
user_id=canonical_user_id,
space_id=space_id,
db_session=db_session,
)
db_session.exec(
delete(ProjectMemory).where(ProjectMemory.space_id == space_id)
)
db_session.exec(delete(SpaceMemory).where(SpaceMemory.space_id == space_id))
db_session.exec(
delete(SpaceFileIndexOverlay).where(
SpaceFileIndexOverlay.space_id == space_id
)
)
db_session.exec(
delete(SpaceFileIndex).where(SpaceFileIndex.space_id == space_id)
)

db_session.delete(space)
db_session.commit()
Loading