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
78 changes: 53 additions & 25 deletions apps/api/src/sibyl/api/routes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class UpdateTaskRequest(BaseModel):
feature: str | None = None
tags: list[str] | None = None
technologies: list[str] | None = None
add_depends_on: list[str] = []
remove_depends_on: list[str] = []


class CreateTaskRequest(BaseModel):
Expand Down Expand Up @@ -567,6 +569,29 @@ async def archive_task(
)


def _build_update_data(request: UpdateTaskRequest, user_id: str) -> dict[str, Any]:
"""Build the update dict from request fields with actor attribution."""
update_data: dict[str, Any] = {"modified_by": user_id}
# Map request fields to entity fields (title → name for graph storage)
field_map: dict[str, str] = {
"status": "status",
"priority": "priority",
"title": "name",
"description": "description",
"assignees": "assignees",
"epic_id": "epic_id",
"feature": "feature",
"complexity": "complexity",
"tags": "tags",
"technologies": "technologies",
}
for req_field, data_key in field_map.items():
value = getattr(request, req_field)
if value is not None:
update_data[data_key] = value
return update_data


@router.patch("/{task_id}", response_model=TaskActionResponse)
async def update_task(
task_id: str,
Expand All @@ -588,31 +613,10 @@ async def update_task(
await _verify_task_access(task_id, org, auth.ctx, auth.session)

group_id = str(org.id)
update_data = _build_update_data(request, str(user.id))

# Build update dict with actor attribution
update_data: dict[str, Any] = {"modified_by": str(user.id)}
if request.status is not None:
update_data["status"] = request.status
if request.priority is not None:
update_data["priority"] = request.priority
if request.title is not None:
update_data["name"] = request.title
if request.description is not None:
update_data["description"] = request.description
if request.assignees is not None:
update_data["assignees"] = request.assignees
if request.epic_id is not None:
update_data["epic_id"] = request.epic_id
if request.feature is not None:
update_data["feature"] = request.feature
if request.complexity is not None:
update_data["complexity"] = request.complexity
if request.tags is not None:
update_data["tags"] = request.tags
if request.technologies is not None:
update_data["technologies"] = request.technologies

if len(update_data) <= 1: # only modified_by
has_dep_changes = bool(request.add_depends_on or request.remove_depends_on)
if len(update_data) <= 1 and not has_dep_changes: # only modified_by
raise HTTPException(status_code=400, detail="No fields to update")

# --- Async fast path (default) ---
Expand All @@ -623,6 +627,8 @@ async def update_task(
group_id,
epic_id=request.epic_id,
new_status=request.status.value if request.status else None,
add_depends_on=request.add_depends_on,
remove_depends_on=request.remove_depends_on,
)
return TaskActionResponse(
success=True,
Expand Down Expand Up @@ -651,8 +657,16 @@ async def update_task(
if not updated:
raise HTTPException(status_code=500, detail="Update failed")

if request.epic_id is not None:
# Create relationship manager if any relationship changes needed
needs_rel_mgr = (
request.epic_id is not None
or request.add_depends_on
or request.remove_depends_on
)
if needs_rel_mgr:
relationship_manager = RelationshipManager(client, group_id=group_id)

if request.epic_id is not None:
belongs_to_epic = Relationship(
id=f"rel_{task_id}_belongs_to_{request.epic_id}",
source_id=task_id,
Expand All @@ -661,6 +675,20 @@ async def update_task(
)
await relationship_manager.create(belongs_to_epic)

# Handle dependency mutations
for dep_id in request.add_depends_on:
dep_rel = Relationship(
id=f"rel_{task_id}_depends_on_{dep_id}",
source_id=task_id,
target_id=dep_id,
relationship_type=RelationshipType.DEPENDS_ON,
)
await relationship_manager.create(dep_rel)
for dep_id in request.remove_depends_on:
await relationship_manager.delete_between(
task_id, dep_id, RelationshipType.DEPENDS_ON
)

if request.status:
epic_id = request.epic_id or updated.metadata.get("epic_id")
await _maybe_start_epic(entity_manager, task_id, epic_id, request.status)
Expand Down
71 changes: 52 additions & 19 deletions apps/api/src/sibyl/jobs/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,14 @@ async def update_task(
group_id: str,
epic_id: str | None = None,
new_status: str | None = None,
add_depends_on: list[str] | None = None,
remove_depends_on: list[str] | None = None,
) -> dict[str, Any]:
"""Update a task asynchronously with epic relationship and auto-start logic.

Task-aware background job that handles concerns the generic update_entity
doesn't: BELONGS_TO epic relationships and epic auto-start on forward progress.
doesn't: BELONGS_TO epic relationships, epic auto-start on forward progress,
and DEPENDS_ON dependency mutations.

Args:
ctx: arq context
Expand All @@ -438,6 +441,8 @@ async def update_task(
group_id: Organization ID
epic_id: Epic ID if being set/changed (triggers BELONGS_TO creation)
new_status: New task status (triggers epic auto-start check)
add_depends_on: Task IDs to add as dependencies
remove_depends_on: Task IDs to remove as dependencies

Returns:
Dict with update results
Expand All @@ -448,11 +453,16 @@ async def update_task(
from sibyl_core.graph.relationships import RelationshipManager
from sibyl_core.models.entities import Relationship, RelationshipType

add_depends_on = add_depends_on or []
remove_depends_on = remove_depends_on or []

log.info(
"update_task_started",
task_id=task_id,
fields=list(updates.keys()),
epic_id=epic_id,
add_deps=len(add_depends_on),
remove_deps=len(remove_depends_on),
)

try:
Expand All @@ -464,15 +474,23 @@ async def update_task(
client = await get_graph_client()
entity_manager = EntityManager(client, group_id=group_id)

# Perform the update
updated = await entity_manager.update(task_id, updates)
if not updated:
log.warning("update_task_no_changes", task_id=task_id)
return {"task_id": task_id, "success": False, "message": "No changes made"}
# Perform the entity field update (skip if only dep changes)
updated = None
if len(updates) > 1: # more than just modified_by
updated = await entity_manager.update(task_id, updates)
if not updated:
log.warning("update_task_no_changes", task_id=task_id)
return {"task_id": task_id, "success": False, "message": "No changes made"}

# Create relationship manager if any relationship changes needed
needs_rel_mgr = (
epic_id is not None or add_depends_on or remove_depends_on
)
if needs_rel_mgr:
relationship_manager = RelationshipManager(client, group_id=group_id)

# Create BELONGS_TO relationship for epic (if epic_id was set/changed)
if epic_id is not None:
relationship_manager = RelationshipManager(client, group_id=group_id)
belongs_to_epic = Relationship(
id=f"rel_{task_id}_belongs_to_{epic_id}",
source_id=task_id,
Expand All @@ -481,24 +499,39 @@ async def update_task(
)
await relationship_manager.create(belongs_to_epic)

# Handle dependency mutations
for dep_id in add_depends_on:
dep_rel = Relationship(
id=f"rel_{task_id}_depends_on_{dep_id}",
source_id=task_id,
target_id=dep_id,
relationship_type=RelationshipType.DEPENDS_ON,
)
await relationship_manager.create(dep_rel)
for dep_id in remove_depends_on:
await relationship_manager.delete_between(
task_id, dep_id, RelationshipType.DEPENDS_ON
)

# Auto-start epic if task moves to forward-progress state
if new_status:
resolved_epic = epic_id or updated.metadata.get("epic_id")
task_entity = updated or await entity_manager.get(task_id)
resolved_epic = epic_id or (
task_entity.metadata.get("epic_id") if task_entity else None
)
if resolved_epic:
await _maybe_start_epic_bg(entity_manager, task_id, resolved_epic, new_status)

# Broadcast outside the lock
await _safe_broadcast(
WSEvent.ENTITY_UPDATED,
{
"id": task_id,
"entity_type": "task",
"action": "update_task",
"name": updated.name,
**updates,
},
org_id=group_id,
)
broadcast_data: dict[str, Any] = {
"id": task_id,
"entity_type": "task",
"action": "update_task",
**updates,
}
if updated:
broadcast_data["name"] = updated.name
await _safe_broadcast(WSEvent.ENTITY_UPDATED, broadcast_data, org_id=group_id)

log.info("update_task_completed", task_id=task_id, fields=list(updates.keys()))
return {
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/sibyl/jobs/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,14 @@ async def enqueue_update_task(
group_id: str,
epic_id: str | None = None,
new_status: str | None = None,
add_depends_on: list[str] | None = None,
remove_depends_on: list[str] | None = None,
) -> str:
"""Enqueue a task update job.

Uses the task-aware ``update_task`` job which handles epic relationships
and epic auto-start — concerns the generic ``update_entity`` doesn't cover.
Uses the task-aware ``update_task`` job which handles epic relationships,
epic auto-start, and dependency mutations — concerns the generic
``update_entity`` doesn't cover.

Job IDs are timestamp-suffixed to prevent arq deduplication from silently
dropping rapid-fire updates to the same task.
Expand All @@ -338,6 +341,8 @@ async def enqueue_update_task(
group_id: Organization ID
epic_id: Epic ID if being set/changed (triggers BELONGS_TO creation)
new_status: New task status string (triggers epic auto-start check)
add_depends_on: Task IDs to add as dependencies
remove_depends_on: Task IDs to remove as dependencies

Returns:
Job ID for tracking
Expand All @@ -357,6 +362,8 @@ async def enqueue_update_task(
group_id,
epic_id=epic_id,
new_status=new_status,
add_depends_on=add_depends_on or [],
remove_depends_on=remove_depends_on or [],
_job_id=job_id,
)

Expand Down
50 changes: 50 additions & 0 deletions apps/cli/src/sibyl_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,50 @@ async def archive_task(self, task_id: str, reason: str | None = None) -> dict[st
data = {"reason": reason} if reason else None
return await self._request("POST", f"/tasks/{task_id}/archive", json=data)

async def create_task(
self,
title: str,
project_id: str,
description: str | None = None,
priority: str = "medium",
complexity: str = "medium",
status: str = "todo",
assignees: list[str] | None = None,
epic_id: str | None = None,
feature: str | None = None,
tags: list[str] | None = None,
technologies: list[str] | None = None,
depends_on: list[str] | None = None,
) -> dict[str, Any]:
"""Create a task via the dedicated POST /tasks endpoint.

Uses the task-specific endpoint which handles BELONGS_TO relationships
and DEPENDS_ON dependencies automatically.
"""
data: dict[str, Any] = {
"title": title,
"project_id": project_id,
"priority": priority,
"complexity": complexity,
"status": status,
}
if description:
data["description"] = description
if assignees:
data["assignees"] = assignees
if epic_id:
data["epic_id"] = epic_id
if feature:
data["feature"] = feature
if tags:
data["tags"] = tags
if technologies:
data["technologies"] = technologies
if depends_on:
data["depends_on"] = depends_on

return await self._request("POST", "/tasks", json=data)

async def update_task(
self,
task_id: str,
Expand All @@ -673,6 +717,8 @@ async def update_task(
feature: str | None = None,
tags: list[str] | None = None,
technologies: list[str] | None = None,
add_depends_on: list[str] | None = None,
remove_depends_on: list[str] | None = None,
) -> dict[str, Any]:
"""Update task fields."""
data: dict[str, Any] = {}
Expand All @@ -696,6 +742,10 @@ async def update_task(
data["tags"] = tags
if technologies:
data["technologies"] = technologies
if add_depends_on:
data["add_depends_on"] = add_depends_on
if remove_depends_on:
data["remove_depends_on"] = remove_depends_on

return await self._request("PATCH", f"/tasks/{task_id}", json=data)

Expand Down
Loading