forked from cardosofelipe/pragma-stack
Compare commits
2 Commits
63066c50ba
...
06b2491c1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b2491c1f | ||
|
|
b8265783f3 |
@@ -31,7 +31,13 @@ from app.crud.syndarix.agent_instance import agent_instance as agent_instance_cr
|
|||||||
from app.crud.syndarix.issue import issue as issue_crud
|
from app.crud.syndarix.issue import issue as issue_crud
|
||||||
from app.crud.syndarix.project import project as project_crud
|
from app.crud.syndarix.project import project as project_crud
|
||||||
from app.crud.syndarix.sprint import sprint as sprint_crud
|
from app.crud.syndarix.sprint import sprint as sprint_crud
|
||||||
from app.models.syndarix.enums import IssuePriority, IssueStatus, SyncStatus
|
from app.models.syndarix.enums import (
|
||||||
|
AgentStatus,
|
||||||
|
IssuePriority,
|
||||||
|
IssueStatus,
|
||||||
|
SprintStatus,
|
||||||
|
SyncStatus,
|
||||||
|
)
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.common import (
|
from app.schemas.common import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
@@ -200,6 +206,12 @@ async def create_issue(
|
|||||||
error_code=ErrorCode.VALIDATION_ERROR,
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
field="assigned_agent_id",
|
field="assigned_agent_id",
|
||||||
)
|
)
|
||||||
|
if agent.status == AgentStatus.TERMINATED:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Cannot assign issue to a terminated agent",
|
||||||
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
field="assigned_agent_id",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate sprint if provided (IDOR prevention)
|
# Validate sprint if provided (IDOR prevention)
|
||||||
if issue_in.sprint_id:
|
if issue_in.sprint_id:
|
||||||
@@ -537,8 +549,14 @@ async def update_issue(
|
|||||||
error_code=ErrorCode.VALIDATION_ERROR,
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
field="assigned_agent_id",
|
field="assigned_agent_id",
|
||||||
)
|
)
|
||||||
|
if agent.status == AgentStatus.TERMINATED:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Cannot assign issue to a terminated agent",
|
||||||
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
field="assigned_agent_id",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate sprint if being updated (IDOR prevention)
|
# Validate sprint if being updated (IDOR prevention and status validation)
|
||||||
if issue_in.sprint_id is not None:
|
if issue_in.sprint_id is not None:
|
||||||
sprint = await sprint_crud.get(db, id=issue_in.sprint_id)
|
sprint = await sprint_crud.get(db, id=issue_in.sprint_id)
|
||||||
if not sprint:
|
if not sprint:
|
||||||
@@ -552,6 +570,13 @@ async def update_issue(
|
|||||||
error_code=ErrorCode.VALIDATION_ERROR,
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
field="sprint_id",
|
field="sprint_id",
|
||||||
)
|
)
|
||||||
|
# Cannot add issues to completed or cancelled sprints
|
||||||
|
if sprint.status in [SprintStatus.COMPLETED, SprintStatus.CANCELLED]:
|
||||||
|
raise ValidationException(
|
||||||
|
message=f"Cannot add issues to sprint with status '{sprint.status.value}'",
|
||||||
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
field="sprint_id",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_issue = await issue_crud.update(db, db_obj=issue, obj_in=issue_in)
|
updated_issue = await issue_crud.update(db, db_obj=issue, obj_in=issue_in)
|
||||||
@@ -730,6 +755,12 @@ async def assign_issue(
|
|||||||
error_code=ErrorCode.VALIDATION_ERROR,
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
field="assigned_agent_id",
|
field="assigned_agent_id",
|
||||||
)
|
)
|
||||||
|
if agent.status == AgentStatus.TERMINATED:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Cannot assign issue to a terminated agent",
|
||||||
|
error_code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
field="assigned_agent_id",
|
||||||
|
)
|
||||||
|
|
||||||
updated_issue = await issue_crud.assign_to_agent(
|
updated_issue = await issue_crud.assign_to_agent(
|
||||||
db, issue_id=issue_id, agent_id=assignment.assigned_agent_id
|
db, issue_id=issue_id, agent_id=assignment.assigned_agent_id
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
|||||||
*,
|
*,
|
||||||
instance_id: UUID,
|
instance_id: UUID,
|
||||||
) -> AgentInstance | None:
|
) -> AgentInstance | None:
|
||||||
"""Terminate an agent instance."""
|
"""Terminate an agent instance.
|
||||||
|
|
||||||
|
Also unassigns all issues from this agent to prevent orphaned assignments.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(AgentInstance).where(AgentInstance.id == instance_id)
|
select(AgentInstance).where(AgentInstance.id == instance_id)
|
||||||
@@ -216,6 +219,13 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
|||||||
if not instance:
|
if not instance:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Unassign all issues from this agent before terminating
|
||||||
|
await db.execute(
|
||||||
|
update(Issue)
|
||||||
|
.where(Issue.assigned_agent_id == instance_id)
|
||||||
|
.values(assigned_agent_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
instance.status = AgentStatus.TERMINATED
|
instance.status = AgentStatus.TERMINATED
|
||||||
instance.terminated_at = datetime.now(UTC)
|
instance.terminated_at = datetime.now(UTC)
|
||||||
instance.current_task = None
|
instance.current_task = None
|
||||||
@@ -308,8 +318,29 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
|||||||
*,
|
*,
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Terminate all active instances in a project."""
|
"""Terminate all active instances in a project.
|
||||||
|
|
||||||
|
Also unassigns all issues from these agents to prevent orphaned assignments.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# First, unassign all issues from agents in this project
|
||||||
|
# Get all agent IDs that will be terminated
|
||||||
|
agents_to_terminate = await db.execute(
|
||||||
|
select(AgentInstance.id).where(
|
||||||
|
AgentInstance.project_id == project_id,
|
||||||
|
AgentInstance.status != AgentStatus.TERMINATED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agent_ids = [row[0] for row in agents_to_terminate.fetchall()]
|
||||||
|
|
||||||
|
# Unassign issues from these agents
|
||||||
|
if agent_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(Issue)
|
||||||
|
.where(Issue.assigned_agent_id.in_(agent_ids))
|
||||||
|
.values(assigned_agent_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
stmt = (
|
stmt = (
|
||||||
update(AgentInstance)
|
update(AgentInstance)
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.crud.base import CRUDBase
|
from app.crud.base import CRUDBase
|
||||||
from app.models.syndarix import AgentInstance, Issue, Project, Sprint
|
from app.models.syndarix import AgentInstance, Issue, Project, Sprint
|
||||||
from app.models.syndarix.enums import ProjectStatus, SprintStatus
|
from app.models.syndarix.enums import AgentStatus, ProjectStatus, SprintStatus
|
||||||
from app.schemas.syndarix import ProjectCreate, ProjectUpdate
|
from app.schemas.syndarix import ProjectCreate, ProjectUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -283,7 +285,13 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
|
|||||||
*,
|
*,
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
) -> Project | None:
|
) -> Project | None:
|
||||||
"""Archive a project by setting status to ARCHIVED."""
|
"""Archive a project by setting status to ARCHIVED.
|
||||||
|
|
||||||
|
This also performs cascading cleanup:
|
||||||
|
- Terminates all active agent instances
|
||||||
|
- Cancels all planned/active sprints
|
||||||
|
- Unassigns issues from terminated agents
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Project).where(Project.id == project_id)
|
select(Project).where(Project.id == project_id)
|
||||||
@@ -293,9 +301,63 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
|
|||||||
if not project:
|
if not project:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# 1. Get all agent IDs that will be terminated
|
||||||
|
agents_to_terminate = await db.execute(
|
||||||
|
select(AgentInstance.id).where(
|
||||||
|
AgentInstance.project_id == project_id,
|
||||||
|
AgentInstance.status != AgentStatus.TERMINATED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agent_ids = [row[0] for row in agents_to_terminate.fetchall()]
|
||||||
|
|
||||||
|
# 2. Unassign issues from these agents to prevent orphaned assignments
|
||||||
|
if agent_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(Issue)
|
||||||
|
.where(Issue.assigned_agent_id.in_(agent_ids))
|
||||||
|
.values(assigned_agent_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Terminate all active agents
|
||||||
|
await db.execute(
|
||||||
|
update(AgentInstance)
|
||||||
|
.where(
|
||||||
|
AgentInstance.project_id == project_id,
|
||||||
|
AgentInstance.status != AgentStatus.TERMINATED,
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
status=AgentStatus.TERMINATED,
|
||||||
|
terminated_at=now,
|
||||||
|
current_task=None,
|
||||||
|
session_id=None,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Cancel all planned/active sprints
|
||||||
|
await db.execute(
|
||||||
|
update(Sprint)
|
||||||
|
.where(
|
||||||
|
Sprint.project_id == project_id,
|
||||||
|
Sprint.status.in_([SprintStatus.PLANNED, SprintStatus.ACTIVE]),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
status=SprintStatus.CANCELLED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Archive the project
|
||||||
project.status = ProjectStatus.ARCHIVED
|
project.status = ProjectStatus.ARCHIVED
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Archived project {project_id}: terminated agents={len(agent_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|||||||
1089
backend/tests/api/routes/syndarix/test_edge_cases.py
Normal file
1089
backend/tests/api/routes/syndarix/test_edge_cases.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user