From 6ab63df82393335f55b73d7626e46aaba7b8ef0b Mon Sep 17 00:00:00 2001 From: 0pfleet Date: Thu, 12 Feb 2026 23:13:43 -0800 Subject: [PATCH 1/3] feat(core): add delete_between() to RelationshipManager Adds a targeted relationship deletion method that removes relationships of a specific type between two entities in a single Cypher query. Used by the dependency CRUD to remove DEPENDS_ON edges without needing to fetch-filter-delete. Co-Authored-By: Claude Opus 4.6 --- .../src/sibyl_core/graph/relationships.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/python/sibyl-core/src/sibyl_core/graph/relationships.py b/packages/python/sibyl-core/src/sibyl_core/graph/relationships.py index e627463..63fd945 100644 --- a/packages/python/sibyl-core/src/sibyl_core/graph/relationships.py +++ b/packages/python/sibyl-core/src/sibyl_core/graph/relationships.py @@ -519,6 +519,70 @@ async def delete(self, relationship_id: str) -> bool: details={"relationship_id": relationship_id}, ) from e + async def delete_between( + self, + source_id: str, + target_id: str, + relationship_type: RelationshipType, + ) -> int: + """Delete relationships of a specific type between source and target. + + Args: + source_id: Source entity UUID. + target_id: Target entity UUID. + relationship_type: Type of relationship to delete. + + Returns: + Number of relationships deleted. + """ + log.info( + "Deleting relationship between entities", + source_id=source_id, + target_id=target_id, + type=relationship_type.value, + ) + + try: + rel_type = _validate_relationship_type(relationship_type.value) + + query = f""" + MATCH (s {{uuid: $source_id}})-[r:{rel_type}]->(t {{uuid: $target_id}}) + WHERE r.group_id = $group_id + DELETE r + RETURN count(r) as deleted + """ + + result = await self._driver.execute_query( + query, + source_id=source_id, + target_id=target_id, + group_id=self._group_id, + ) + + rows = self._client.normalize_result(result) + deleted = ( + rows[0]["deleted"] + if rows and isinstance(rows[0], dict) + else (rows[0][0] if rows else 0) + ) + + log.info( + "Deleted relationships between entities", + source_id=source_id, + target_id=target_id, + count=deleted, + ) + return deleted + + except Exception as e: + log.warning( + "Failed to delete relationships between entities", + error=str(e), + source_id=source_id, + target_id=target_id, + ) + return 0 + async def delete_for_entity(self, entity_id: str) -> int: """Delete all relationships for an entity. From 7da887c4d5aff9836d631c03f8f2f1b4ab636fee Mon Sep 17 00:00:00 2001 From: 0pfleet Date: Thu, 12 Feb 2026 23:13:52 -0800 Subject: [PATCH 2/3] feat(api): add dependency mutations to PATCH /tasks/{task_id} Adds add_depends_on/remove_depends_on fields to UpdateTaskRequest, enabling dependency management after task creation. Both sync and async (worker) paths handle DEPENDS_ON relationship creation and deletion. Extracts _build_update_data() helper to stay under PLR0915 statement limit after adding the new fields. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/sibyl/api/routes/tasks.py | 78 +++++++++++++++++--------- apps/api/src/sibyl/jobs/entities.py | 71 ++++++++++++++++------- apps/api/src/sibyl/jobs/queue.py | 11 +++- 3 files changed, 114 insertions(+), 46 deletions(-) diff --git a/apps/api/src/sibyl/api/routes/tasks.py b/apps/api/src/sibyl/api/routes/tasks.py index d1b5afc..732f9b0 100644 --- a/apps/api/src/sibyl/api/routes/tasks.py +++ b/apps/api/src/sibyl/api/routes/tasks.py @@ -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): @@ -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, @@ -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) --- @@ -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, @@ -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, @@ -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) diff --git a/apps/api/src/sibyl/jobs/entities.py b/apps/api/src/sibyl/jobs/entities.py index 77fc3ac..da28ec1 100644 --- a/apps/api/src/sibyl/jobs/entities.py +++ b/apps/api/src/sibyl/jobs/entities.py @@ -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 @@ -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 @@ -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: @@ -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, @@ -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 { diff --git a/apps/api/src/sibyl/jobs/queue.py b/apps/api/src/sibyl/jobs/queue.py index 70353c3..0b7bee5 100644 --- a/apps/api/src/sibyl/jobs/queue.py +++ b/apps/api/src/sibyl/jobs/queue.py @@ -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. @@ -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 @@ -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, ) From f2da371f804ecd33555fe48d296b37406534b395 Mon Sep 17 00:00:00 2001 From: 0pfleet Date: Thu, 12 Feb 2026 23:13:58 -0800 Subject: [PATCH 3/3] feat(cli): wire dependency flags into task create/update commands - create: Add --depends-on flag, switch from generic POST /entities to dedicated POST /tasks endpoint (create_direct, no worker queue) - update: Add --add-dep and --remove-dep flags for post-creation dependency management - client: Add create_task() method, add dep params to update_task() The create command now uses the task-specific endpoint which handles BELONGS_TO and DEPENDS_ON relationships synchronously, eliminating the need for the --sync flag. Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/sibyl_cli/client.py | 50 ++++++++++++++++++++++ apps/cli/src/sibyl_cli/task.py | 71 ++++++++++++++++++-------------- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/sibyl_cli/client.py b/apps/cli/src/sibyl_cli/client.py index d5cf484..8f06653 100644 --- a/apps/cli/src/sibyl_cli/client.py +++ b/apps/cli/src/sibyl_cli/client.py @@ -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, @@ -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] = {} @@ -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) diff --git a/apps/cli/src/sibyl_cli/task.py b/apps/cli/src/sibyl_cli/task.py index 6585fa7..ac2c951 100644 --- a/apps/cli/src/sibyl_cli/task.py +++ b/apps/cli/src/sibyl_cli/task.py @@ -744,10 +744,9 @@ def create_task( technologies: Annotated[ str | None, typer.Option("--tech", help="Comma-separated technologies") ] = None, - sync: Annotated[ - bool, - typer.Option("--sync", help="Wait for task creation (slower but immediately available)"), - ] = False, + depends_on: Annotated[ + str | None, typer.Option("--depends-on", help="Comma-separated task IDs this depends on") + ] = None, json_out: Annotated[ bool, typer.Option("--json", "-j", help="JSON output (for scripting)") ] = False, @@ -774,43 +773,35 @@ async def _create() -> None: tech_list = [t.strip() for t in technologies.split(",")] if technologies else None tag_list = [t.strip() for t in tags.split(",")] if tags else None assignee_list = [assignee] if assignee else None + dep_list = [d.strip() for d in depends_on.split(",")] if depends_on else None - # Build metadata - metadata: dict = { - "project_id": effective_project, - "priority": priority, - "complexity": complexity, - "status": "todo", - } - if assignee_list: - metadata["assignees"] = assignee_list - if tech_list: - metadata["technologies"] = tech_list - if tag_list: - metadata["tags"] = tag_list - if feature: - metadata["feature"] = feature - if epic: - metadata["epic_id"] = epic - - response = await client.create_entity( - name=title, - content=description or title, - entity_type="task", - metadata=metadata, - sync=sync, + response = await client.create_task( + title=title, + project_id=effective_project, + description=description, + priority=priority, + complexity=complexity, + assignees=assignee_list, + epic_id=epic, + feature=feature, + tags=tag_list, + technologies=tech_list, + depends_on=dep_list, ) if json_out: print_json(response) return - if response.get("id"): - success(f"Task created: {response['id']}") + task_id = response.get("task_id") or response.get("id") + if response.get("success") or task_id: + success(f"Task created: {task_id}") if assignee: info(f"Assigned to: {assignee}") + if dep_list: + info(f"Dependencies: {', '.join(dep_list)}") else: - error("Failed to create task") + error(f"Failed to create task: {response.get('message', 'Unknown error')}") except SibylClientError as e: _handle_client_error(e) @@ -845,6 +836,14 @@ def update_task( technologies: Annotated[ str | None, typer.Option("--tech", help="Comma-separated technologies (replaces existing)") ] = None, + add_dep: Annotated[ + str | None, + typer.Option("--add-dep", help="Comma-separated task IDs to add as dependencies"), + ] = None, + remove_dep: Annotated[ + str | None, + typer.Option("--remove-dep", help="Comma-separated task IDs to remove as dependencies"), + ] = None, json_out: Annotated[ bool, typer.Option("--json", "-j", help="JSON output (for scripting)") ] = False, @@ -869,10 +868,14 @@ async def _update() -> None: feature, tags, technologies, + add_dep, + remove_dep, ] ): error( - "No fields to update. Use --status, --priority, --complexity, --title, --description, --assignee, --epic, --feature, --tags, or --tech" + "No fields to update. Use --status, --priority, --complexity, --title, " + "--description, --assignee, --epic, --feature, --tags, --tech, " + "--add-dep, or --remove-dep" ) return @@ -880,6 +883,8 @@ async def _update() -> None: assignees = [assignee] if assignee else None tag_list = [t.strip() for t in tags.split(",")] if tags else None tech_list = [t.strip() for t in technologies.split(",")] if technologies else None + add_dep_list = [d.strip() for d in add_dep.split(",")] if add_dep else None + remove_dep_list = [d.strip() for d in remove_dep.split(",")] if remove_dep else None response = await client.update_task( task_id=resolved_id, @@ -893,6 +898,8 @@ async def _update() -> None: feature=feature, tags=tag_list, technologies=tech_list, + add_depends_on=add_dep_list, + remove_depends_on=remove_dep_list, ) if json_out: