feat(F): F-PROACTIVE consolidate proactive AI — trigger_dispatcher dispatches agents on context/UI events
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -100,6 +100,13 @@ class TriggerDispatcher:
|
|||||||
trigger_type=trigger_type,
|
trigger_type=trigger_type,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
# F-PROACTIVE: Also check for matching agent definitions on context/UI events
|
||||||
|
if is_ui_event or event_name.startswith("context."):
|
||||||
|
await self._dispatch_matching_agents(
|
||||||
|
event_name=event_name,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"TriggerDispatcher: error dispatching event '%s'", event_name
|
"TriggerDispatcher: error dispatching event '%s'", event_name
|
||||||
@@ -164,6 +171,72 @@ class TriggerDispatcher:
|
|||||||
trigger_data=payload,
|
trigger_data=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _dispatch_matching_agents(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
trigger_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""F-PROACTIVE: Query DB for active agents matching *event_name* and dispatch.
|
||||||
|
|
||||||
|
Checks AgentDefinition.trigger_config for matching context/UI events.
|
||||||
|
If match found, creates an AgentRun and dispatches via run_agent.
|
||||||
|
"""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.plugins.builtins.contracts import get_contract
|
||||||
|
|
||||||
|
automation_contract = get_contract("automation")
|
||||||
|
if automation_contract is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
AgentDefinition = automation_contract.AgentDefinition # noqa: N806
|
||||||
|
if AgentDefinition is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
tenant_id = payload.get("tenant_id")
|
||||||
|
|
||||||
|
async with factory() as db:
|
||||||
|
query = (
|
||||||
|
select(AgentDefinition)
|
||||||
|
.where(AgentDefinition.is_active.is_(True))
|
||||||
|
.where(AgentDefinition.mode == "proactive")
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.where(AgentDefinition.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
agents = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not agents:
|
||||||
|
return
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
config = agent.trigger_config or {}
|
||||||
|
configured_event = config.get("event_name", "")
|
||||||
|
if configured_event != event_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TriggerDispatcher: dispatching agent '%s' (%s) for event '%s'",
|
||||||
|
agent.name,
|
||||||
|
agent.id,
|
||||||
|
event_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await automation_contract.run_agent(
|
||||||
|
ctx={},
|
||||||
|
agent_id=str(agent.id),
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
trigger_data=payload,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"TriggerDispatcher: run_agent failed for agent_id=%s",
|
||||||
|
agent.id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _enqueue_automation(
|
async def _enqueue_automation(
|
||||||
self,
|
self,
|
||||||
automation_id: str,
|
automation_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""AI Tools for the Tool Registry — task tools for the AI Assistant.
|
||||||
|
|
||||||
|
Each tool is an async function (arguments: dict, context: dict) -> str
|
||||||
|
that delegates to the tasks service layer and returns a JSON-string result.
|
||||||
|
All tools require the ``tasks:write`` permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.plugins.builtins.tasks import services
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db_and_tenant(context: dict[str, Any]) -> tuple[AsyncSession, uuid.UUID, uuid.UUID]:
|
||||||
|
"""Extract tenant_id, user_id from context and return the DB session."""
|
||||||
|
tenant_id = uuid.UUID(context.get("tenant_id", ""))
|
||||||
|
user_id = uuid.UUID(context.get("user_id", ""))
|
||||||
|
db = context.get("db")
|
||||||
|
if db is not None and isinstance(db, AsyncSession):
|
||||||
|
return db, tenant_id, user_id
|
||||||
|
raise RuntimeError("No db session in context")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_task_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Create a new task (supports polymorphic assignee/entity/creator)."""
|
||||||
|
try:
|
||||||
|
db, tenant_id, user_id = _get_db_and_tenant(context)
|
||||||
|
data = dict(arguments)
|
||||||
|
result = await services.create_task(db, tenant_id, user_id, data)
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("create_task_handler failed")
|
||||||
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_task_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Assign a task to a user, agent, or group (polymorphic)."""
|
||||||
|
try:
|
||||||
|
db, tenant_id, _ = _get_db_and_tenant(context)
|
||||||
|
task_id = uuid.UUID(arguments["task_id"])
|
||||||
|
assignee_type = arguments.get("assignee_type", "user")
|
||||||
|
assignee_id = uuid.UUID(arguments["assignee_id"]) if arguments.get("assignee_id") else None
|
||||||
|
result = await services.assign_task(
|
||||||
|
db, tenant_id, task_id, None,
|
||||||
|
assignee_type=assignee_type, assignee_id=assignee_id,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return json.dumps({"error": "Task not found"})
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("assign_task_handler failed")
|
||||||
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def update_task_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Update a task's lifecycle status."""
|
||||||
|
try:
|
||||||
|
db, tenant_id, _ = _get_db_and_tenant(context)
|
||||||
|
task_id = uuid.UUID(arguments["task_id"])
|
||||||
|
status = arguments["status"]
|
||||||
|
result = await services.update_task_status(db, tenant_id, task_id, status)
|
||||||
|
if result is None:
|
||||||
|
return json.dumps({"error": "Task not found"})
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("update_task_status_handler failed")
|
||||||
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def decompose_goal_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Decompose a goal into subtasks (milestones/todos)."""
|
||||||
|
try:
|
||||||
|
db, tenant_id, user_id = _get_db_and_tenant(context)
|
||||||
|
goal_id = uuid.UUID(arguments["goal_id"])
|
||||||
|
subtasks = arguments.get("subtasks") or []
|
||||||
|
result = await services.decompose_goal(db, tenant_id, user_id, goal_id, subtasks)
|
||||||
|
if result is None:
|
||||||
|
return json.dumps({"error": "Goal not found"})
|
||||||
|
return json.dumps(result, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("decompose_goal_handler failed")
|
||||||
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Registration ───
|
||||||
|
|
||||||
|
|
||||||
|
def register_task_tools(registry) -> None:
|
||||||
|
"""Register task AI tools into the existing Tool Registry."""
|
||||||
|
registry.register(
|
||||||
|
name="create_task",
|
||||||
|
description="Neue Aufgabe erstellen (Unified Task System). Unterstützt polymorphe Zuweisung (user/agent/group), Entity-Verknüpfung (contact/company/...), Subtasks, Dependencies, Goals/Milestones und Agent-Subtasks.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Aufgabentitel"},
|
||||||
|
"description": {"type": "string", "description": "Beschreibung"},
|
||||||
|
"status": {"type": "string", "enum": ["open", "in_progress", "review", "blocked", "done", "cancelled"], "default": "open"},
|
||||||
|
"priority": {"type": "string", "enum": ["low", "medium", "high", "urgent"], "default": "medium"},
|
||||||
|
"due_date": {"type": "string", "description": "ISO Datum"},
|
||||||
|
"assignee_type": {"type": "string", "enum": ["user", "agent", "group"], "default": "user"},
|
||||||
|
"assignee_id": {"type": "string", "description": "UUID des Assignees"},
|
||||||
|
"entity_type": {"type": "string", "description": "z.B. contact, company"},
|
||||||
|
"entity_id": {"type": "string", "description": "UUID der Entity"},
|
||||||
|
"parent_task_id": {"type": "string", "description": "UUID des Parent-Tasks (Subtask)"},
|
||||||
|
"depends_on": {"type": "array", "items": {"type": "string"}, "description": "Task-IDs von denen diese Aufgabe abhängt"},
|
||||||
|
"task_type": {"type": "string", "enum": ["todo", "approval", "follow_up", "review", "goal", "milestone", "agent_subtask"], "default": "todo"},
|
||||||
|
"success_criteria": {"type": "object", "description": "Strukturierte Erfolgskriterien für Goals"},
|
||||||
|
"target_date": {"type": "string", "description": "Deadline für Goal/Milestone"},
|
||||||
|
},
|
||||||
|
"required": ["title"],
|
||||||
|
},
|
||||||
|
handler=create_task_handler,
|
||||||
|
plugin_name="tasks",
|
||||||
|
required_permission="tasks:write",
|
||||||
|
category="tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="assign_task",
|
||||||
|
description="Aufgabe einem User, Agent oder einer Gruppe zuweisen (polymorph).",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "string", "description": "UUID der Aufgabe"},
|
||||||
|
"assignee_type": {"type": "string", "enum": ["user", "agent", "group"], "default": "user"},
|
||||||
|
"assignee_id": {"type": "string", "description": "UUID des Assignees"},
|
||||||
|
},
|
||||||
|
"required": ["task_id", "assignee_id"],
|
||||||
|
},
|
||||||
|
handler=assign_task_handler,
|
||||||
|
plugin_name="tasks",
|
||||||
|
required_permission="tasks:write",
|
||||||
|
category="tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="update_task_status",
|
||||||
|
description="Status einer Aufgabe aktualisieren (open/in_progress/review/blocked/done/cancelled).",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "string", "description": "UUID der Aufgabe"},
|
||||||
|
"status": {"type": "string", "enum": ["open", "in_progress", "review", "blocked", "done", "cancelled"]},
|
||||||
|
},
|
||||||
|
"required": ["task_id", "status"],
|
||||||
|
},
|
||||||
|
handler=update_task_status_handler,
|
||||||
|
plugin_name="tasks",
|
||||||
|
required_permission="tasks:write",
|
||||||
|
category="tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="decompose_goal",
|
||||||
|
description="Ein Goal in Subtasks (Milestones/Todos) zerlegen.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"goal_id": {"type": "string", "description": "UUID des Goals"},
|
||||||
|
"subtasks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"milestone": {"type": "boolean", "default": False},
|
||||||
|
"assignee_type": {"type": "string", "enum": ["user", "agent", "group"]},
|
||||||
|
"assignee_id": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["goal_id", "subtasks"],
|
||||||
|
},
|
||||||
|
handler=decompose_goal_handler,
|
||||||
|
plugin_name="tasks",
|
||||||
|
required_permission="tasks:write",
|
||||||
|
category="tasks",
|
||||||
|
)
|
||||||
|
logger.info("Task AI tools registered")
|
||||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -14,7 +16,13 @@ from app.models.owned_mixin import OwnedMixin
|
|||||||
|
|
||||||
|
|
||||||
class Task(Base, TenantMixin, OwnedMixin):
|
class Task(Base, TenantMixin, OwnedMixin):
|
||||||
"""Task entity — free activities (calls, notes, visits) linked to contacts."""
|
"""Task entity — free activities (calls, notes, visits) linked to entities.
|
||||||
|
|
||||||
|
Supports polymorphic assignment (user/agent/group), polymorphic entity
|
||||||
|
links (contact/company/...), subtasks, dependencies, goals/milestones and
|
||||||
|
agent subtasks. Legacy ``contact_id`` and ``assigned_to`` columns are kept
|
||||||
|
for backward compatibility and mirrored into the polymorphic fields.
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -23,6 +31,10 @@ class Task(Base, TenantMixin, OwnedMixin):
|
|||||||
Index("ix_tasks_tenant_assigned", "tenant_id", "assigned_to"),
|
Index("ix_tasks_tenant_assigned", "tenant_id", "assigned_to"),
|
||||||
Index("ix_tasks_tenant_due", "tenant_id", "due_date"),
|
Index("ix_tasks_tenant_due", "tenant_id", "due_date"),
|
||||||
Index("ix_tasks_contact", "contact_id"),
|
Index("ix_tasks_contact", "contact_id"),
|
||||||
|
Index("ix_tasks_tenant_entity", "tenant_id", "entity_type", "entity_id"),
|
||||||
|
Index("ix_tasks_tenant_assignee", "tenant_id", "assignee_type", "assignee_id"),
|
||||||
|
Index("ix_tasks_tenant_parent", "tenant_id", "parent_task_id"),
|
||||||
|
Index("ix_tasks_tenant_type", "tenant_id", "task_type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -32,7 +44,7 @@ class Task(Base, TenantMixin, OwnedMixin):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="open"
|
String(20), nullable=False, default="open"
|
||||||
) # open, in_progress, done
|
) # open, in_progress, review, blocked, done, cancelled
|
||||||
priority: Mapped[str] = mapped_column(
|
priority: Mapped[str] = mapped_column(
|
||||||
String(10), nullable=False, default="medium"
|
String(10), nullable=False, default="medium"
|
||||||
) # low, medium, high, urgent
|
) # low, medium, high, urgent
|
||||||
@@ -49,3 +61,46 @@ class Task(Base, TenantMixin, OwnedMixin):
|
|||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# ── F.14 Unified Task System ────────────────────────────────────────────
|
||||||
|
# Polymorphic assignee (user/agent/group)
|
||||||
|
assignee_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="user"
|
||||||
|
) # user, agent, group
|
||||||
|
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polymorphic entity link (contact/company/...)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
|
|
||||||
|
# Polymorphic creator (user/agent/workflow/system)
|
||||||
|
creator_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="user"
|
||||||
|
) # user, agent, workflow, system
|
||||||
|
creator_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subtasks (self-reference)
|
||||||
|
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Task dependencies (blocked-by task IDs)
|
||||||
|
depends_on: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
|
||||||
|
# Task type
|
||||||
|
task_type: Mapped[str] = mapped_column(
|
||||||
|
String(30), nullable=False, default="todo"
|
||||||
|
) # todo, approval, follow_up, review, goal, milestone, agent_subtask
|
||||||
|
|
||||||
|
# Goal / Milestone support
|
||||||
|
success_criteria: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
target_date: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 0-100
|
||||||
|
|||||||
@@ -76,9 +76,18 @@ class TasksPlugin(BasePlugin):
|
|||||||
return {"task": Task}
|
return {"task": Task}
|
||||||
|
|
||||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||||
"""Activate plugin: register restore config + history hooks."""
|
"""Activate plugin: register restore config + history hooks + AI tools."""
|
||||||
await super().on_activate(db, service_container, event_bus)
|
await super().on_activate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
# Register task AI tools (F-TASK-AGENT)
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||||
|
from app.plugins.builtins.tasks.ai_tools import register_task_tools
|
||||||
|
register_task_tools(get_tool_registry())
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("Failed to register task AI tools")
|
||||||
|
|
||||||
# Register restore config for Task entities (P0-7 fix)
|
# Register restore config for Task entities (P0-7 fix)
|
||||||
from app.core.restore_registry import RestoreConfig, get_restore_registry
|
from app.core.restore_registry import RestoreConfig, get_restore_registry
|
||||||
from app.plugins.builtins.tasks.models import Task
|
from app.plugins.builtins.tasks.models import Task
|
||||||
@@ -101,7 +110,15 @@ class TasksPlugin(BasePlugin):
|
|||||||
async def on_deactivate(
|
async def on_deactivate(
|
||||||
self, db, service_container, event_bus
|
self, db, service_container, event_bus
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Deactivate plugin: unregister contract, restore, history, events."""
|
"""Deactivate plugin: unregister contract, restore, history, events, AI tools."""
|
||||||
|
# Unregister task AI tools (F-TASK-AGENT)
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||||
|
get_tool_registry().unregister_plugin("tasks")
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("Failed to unregister task AI tools")
|
||||||
|
|
||||||
# Contract abmelden
|
# Contract abmelden
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
get_contract_registry().unregister(self.manifest.name)
|
get_contract_registry().unregister(self.manifest.name)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tasks plugin routes — CRUD, assign, status update."""
|
"""Tasks plugin routes — CRUD, assign, status update, subtasks, dependencies."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ from app.plugins.builtins.tasks import services
|
|||||||
from app.plugins.builtins.tasks.schemas import (
|
from app.plugins.builtins.tasks.schemas import (
|
||||||
TaskAssignRequest,
|
TaskAssignRequest,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
|
TaskDependencyRequest,
|
||||||
TaskStatusRequest,
|
TaskStatusRequest,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
)
|
)
|
||||||
@@ -20,6 +21,11 @@ from app.plugins.builtins.tasks.schemas import (
|
|||||||
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
TASK_STATUS_PATTERN = "^(open|in_progress|review|blocked|done|cancelled)$"
|
||||||
|
TASK_TYPE_PATTERN = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$"
|
||||||
|
ASSIGNEE_TYPE_PATTERN = "^(user|agent|group)$"
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(val)
|
return uuid.UUID(val)
|
||||||
@@ -33,10 +39,16 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
|||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(25, ge=1, le=100),
|
page_size: int = Query(25, ge=1, le=100),
|
||||||
status: str | None = Query(None, pattern="^(open|in_progress|done)$"),
|
status: str | None = Query(None, pattern=TASK_STATUS_PATTERN),
|
||||||
priority: str | None = Query(None, pattern="^(low|medium|high|urgent)$"),
|
priority: str | None = Query(None, pattern="^(low|medium|high|urgent)$"),
|
||||||
assigned_to: str | None = Query(None),
|
assigned_to: str | None = Query(None),
|
||||||
contact_id: str | None = Query(None),
|
contact_id: str | None = Query(None),
|
||||||
|
entity_type: str | None = Query(None),
|
||||||
|
entity_id: str | None = Query(None),
|
||||||
|
assignee_type: str | None = Query(None, pattern=ASSIGNEE_TYPE_PATTERN),
|
||||||
|
assignee_id: str | None = Query(None),
|
||||||
|
parent_task_id: str | None = Query(None),
|
||||||
|
task_type: str | None = Query(None, pattern=TASK_TYPE_PATTERN),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@@ -50,6 +62,9 @@ async def list_tasks(
|
|||||||
page=page, page_size=page_size,
|
page=page, page_size=page_size,
|
||||||
status=status, priority=priority,
|
status=status, priority=priority,
|
||||||
assigned_to=assigned_to, contact_id=contact_id,
|
assigned_to=assigned_to, contact_id=contact_id,
|
||||||
|
entity_type=entity_type, entity_id=entity_id,
|
||||||
|
assignee_type=assignee_type, assignee_id=assignee_id,
|
||||||
|
parent_task_id=parent_task_id, task_type=task_type,
|
||||||
search=search,
|
search=search,
|
||||||
user_id=user_id, is_system_admin=is_system_admin,
|
user_id=user_id, is_system_admin=is_system_admin,
|
||||||
)
|
)
|
||||||
@@ -122,11 +137,15 @@ async def assign_task(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Assign a task to a user."""
|
"""Assign a task to a user, agent, or group (polymorphic)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
tid = _parse_uuid(task_id, "task_id")
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
assigned_to = _parse_uuid(body.assigned_to, "assigned_to")
|
assigned_to = _parse_uuid(body.assigned_to, "assigned_to") if body.assigned_to else None
|
||||||
result = await services.assign_task(db, tenant_id, tid, assigned_to)
|
assignee_id = _parse_uuid(body.assignee_id, "assignee_id") if body.assignee_id else None
|
||||||
|
result = await services.assign_task(
|
||||||
|
db, tenant_id, tid, assigned_to,
|
||||||
|
assignee_type=body.assignee_type, assignee_id=assignee_id,
|
||||||
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||||
return result
|
return result
|
||||||
@@ -146,3 +165,94 @@ async def update_task_status(
|
|||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── F.14 Subtasks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/subtasks", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tasks:write"))])
|
||||||
|
async def create_subtask(
|
||||||
|
task_id: str,
|
||||||
|
body: TaskCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a subtask under a parent task."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
|
result = await services.create_subtask(db, tenant_id, user_id, tid, body.model_dump())
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Parent task not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/subtasks", dependencies=[Depends(require_permission("tasks:read"))])
|
||||||
|
async def list_subtasks(
|
||||||
|
task_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List subtasks for a parent task."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
|
return await services.list_subtasks(db, tenant_id, tid)
|
||||||
|
|
||||||
|
|
||||||
|
# ── F.14 Dependencies ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/dependencies", dependencies=[Depends(require_permission("tasks:write"))])
|
||||||
|
async def add_dependency(
|
||||||
|
task_id: str,
|
||||||
|
body: TaskDependencyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Add a dependency (this task depends on another task)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
|
dep_id = _parse_uuid(body.depends_on, "depends_on")
|
||||||
|
result = await services.add_dependency(db, tenant_id, tid, dep_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Task or dependency not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{task_id}/dependencies/{depends_on}", dependencies=[Depends(require_permission("tasks:write"))])
|
||||||
|
async def remove_dependency(
|
||||||
|
task_id: str,
|
||||||
|
depends_on: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Remove a dependency from a task."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
|
dep_id = _parse_uuid(depends_on, "depends_on")
|
||||||
|
result = await services.remove_dependency(db, tenant_id, tid, dep_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── F.14 Goal decomposition ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/decompose", dependencies=[Depends(require_permission("tasks:write"))])
|
||||||
|
async def decompose_goal(
|
||||||
|
task_id: str,
|
||||||
|
body: list[TaskCreate],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Decompose a goal into subtasks (milestones/todos)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
tid = _parse_uuid(task_id, "task_id")
|
||||||
|
result = await services.decompose_goal(
|
||||||
|
db, tenant_id, user_id, tid, [item.model_dump() for item in body]
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Goal not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|||||||
@@ -3,40 +3,79 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
TASK_STATUSES = "^(open|in_progress|review|blocked|done|cancelled)$"
|
||||||
|
TASK_TYPES = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$"
|
||||||
|
ASSIGNEE_TYPES = "^(user|agent|group)$"
|
||||||
|
CREATOR_TYPES = "^(user|agent|workflow|system)$"
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str = Field(default="open", pattern="^(open|in_progress|done)$")
|
status: str = Field(default="open", pattern=TASK_STATUSES)
|
||||||
priority: str = Field(default="medium", pattern="^(low|medium|high|urgent)$")
|
priority: str = Field(default="medium", pattern="^(low|medium|high|urgent)$")
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
assigned_to: str | None = None
|
assigned_to: str | None = None
|
||||||
contact_id: str | None = None
|
contact_id: str | None = None
|
||||||
|
# F.14 polymorphic fields
|
||||||
|
assignee_type: str = Field(default="user", pattern=ASSIGNEE_TYPES)
|
||||||
|
assignee_id: str | None = None
|
||||||
|
entity_type: str | None = None
|
||||||
|
entity_id: str | None = None
|
||||||
|
creator_type: str = Field(default="user", pattern=CREATOR_TYPES)
|
||||||
|
creator_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
depends_on: list[str] = Field(default_factory=list)
|
||||||
|
task_type: str = Field(default="todo", pattern=TASK_TYPES)
|
||||||
|
success_criteria: dict[str, Any] | None = None
|
||||||
|
target_date: datetime | None = None
|
||||||
|
progress: int = Field(default=0, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
"""Schema for updating a task."""
|
"""Schema for updating a task."""
|
||||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str | None = Field(default=None, pattern="^(open|in_progress|done)$")
|
status: str | None = Field(default=None, pattern=TASK_STATUSES)
|
||||||
priority: str | None = Field(default=None, pattern="^(low|medium|high|urgent)$")
|
priority: str | None = Field(default=None, pattern="^(low|medium|high|urgent)$")
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
assigned_to: str | None = None
|
assigned_to: str | None = None
|
||||||
contact_id: str | None = None
|
contact_id: str | None = None
|
||||||
|
assignee_type: str | None = Field(default=None, pattern=ASSIGNEE_TYPES)
|
||||||
|
assignee_id: str | None = None
|
||||||
|
entity_type: str | None = None
|
||||||
|
entity_id: str | None = None
|
||||||
|
creator_type: str | None = Field(default=None, pattern=CREATOR_TYPES)
|
||||||
|
creator_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
depends_on: list[str] | None = None
|
||||||
|
task_type: str | None = Field(default=None, pattern=TASK_TYPES)
|
||||||
|
success_criteria: dict[str, Any] | None = None
|
||||||
|
target_date: datetime | None = None
|
||||||
|
progress: int | None = Field(default=None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
class TaskAssignRequest(BaseModel):
|
class TaskAssignRequest(BaseModel):
|
||||||
"""Schema for assigning a task."""
|
"""Schema for assigning a task (polymorphic)."""
|
||||||
assigned_to: str = Field(..., description="User ID to assign the task to")
|
assigned_to: str | None = None
|
||||||
|
assignee_type: str = Field(default="user", pattern=ASSIGNEE_TYPES)
|
||||||
|
assignee_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusRequest(BaseModel):
|
class TaskStatusRequest(BaseModel):
|
||||||
"""Schema for updating task status."""
|
"""Schema for updating task status."""
|
||||||
status: str = Field(..., pattern="^(open|in_progress|done)$")
|
status: str = Field(..., pattern=TASK_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDependencyRequest(BaseModel):
|
||||||
|
"""Schema for adding/removing a task dependency."""
|
||||||
|
depends_on: str = Field(..., description="Task ID this task depends on")
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -49,6 +88,18 @@ class TaskResponse(BaseModel):
|
|||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
assigned_to: str | None = None
|
assigned_to: str | None = None
|
||||||
contact_id: str | None = None
|
contact_id: str | None = None
|
||||||
|
assignee_type: str = "user"
|
||||||
|
assignee_id: str | None = None
|
||||||
|
entity_type: str | None = None
|
||||||
|
entity_id: str | None = None
|
||||||
|
creator_type: str = "user"
|
||||||
|
creator_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
depends_on: list[str] = Field(default_factory=list)
|
||||||
|
task_type: str = "todo"
|
||||||
|
success_criteria: dict[str, Any] | None = None
|
||||||
|
target_date: datetime | None = None
|
||||||
|
progress: int = 0
|
||||||
created_by: str | None = None
|
created_by: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ from app.core.visibility import apply_visibility_filter
|
|||||||
from app.plugins.builtins.tasks.models import Task
|
from app.plugins.builtins.tasks.models import Task
|
||||||
|
|
||||||
|
|
||||||
|
# Lifecycle statuses in display order (Kanban columns)
|
||||||
|
STATUS_ORDER = ["open", "in_progress", "review", "blocked", "done", "cancelled"]
|
||||||
|
# Statuses that count as "done" for progress aggregation
|
||||||
|
DONE_STATUSES = {"done"}
|
||||||
|
# Statuses that count as "in progress" for progress aggregation
|
||||||
|
ACTIVE_STATUSES = {"in_progress", "review"}
|
||||||
|
|
||||||
|
|
||||||
def _task_to_dict(task: Task) -> dict[str, Any]:
|
def _task_to_dict(task: Task) -> dict[str, Any]:
|
||||||
"""Serialize a Task model to a dict."""
|
"""Serialize a Task model to a dict."""
|
||||||
return {
|
return {
|
||||||
@@ -24,12 +32,157 @@ def _task_to_dict(task: Task) -> dict[str, Any]:
|
|||||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||||
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
|
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
|
||||||
"contact_id": str(task.contact_id) if task.contact_id else None,
|
"contact_id": str(task.contact_id) if task.contact_id else None,
|
||||||
|
"assignee_type": task.assignee_type,
|
||||||
|
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
|
||||||
|
"entity_type": task.entity_type,
|
||||||
|
"entity_id": str(task.entity_id) if task.entity_id else None,
|
||||||
|
"creator_type": task.creator_type,
|
||||||
|
"creator_id": str(task.creator_id) if task.creator_id else None,
|
||||||
|
"parent_task_id": str(task.parent_task_id) if task.parent_task_id else None,
|
||||||
|
"depends_on": [str(d) for d in (task.depends_on or [])],
|
||||||
|
"task_type": task.task_type,
|
||||||
|
"success_criteria": task.success_criteria,
|
||||||
|
"target_date": task.target_date.isoformat() if task.target_date else None,
|
||||||
|
"progress": task.progress or 0,
|
||||||
"created_by": str(task.created_by) if task.created_by else None,
|
"created_by": str(task.created_by) if task.created_by else None,
|
||||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||||
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> Task | None:
|
||||||
|
"""Fetch a non-deleted task by id within a tenant."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Task).where(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.tenant_id == tenant_id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _recompute_progress(db: AsyncSession, tenant_id: uuid.UUID, task: Task) -> None:
|
||||||
|
"""Recompute a parent task's progress from its child tasks.
|
||||||
|
|
||||||
|
Progress = percentage of non-cancelled children that are done. If there are
|
||||||
|
no children, the task's own progress field is left untouched.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Task).where(
|
||||||
|
Task.tenant_id == tenant_id,
|
||||||
|
Task.parent_task_id == task.id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
children = result.scalars().all()
|
||||||
|
if not children:
|
||||||
|
return
|
||||||
|
active = [c for c in children if c.status != "cancelled"]
|
||||||
|
if not active:
|
||||||
|
task.progress = 100
|
||||||
|
return
|
||||||
|
done_count = sum(1 for c in active if c.status in DONE_STATUSES)
|
||||||
|
task.progress = round(done_count / len(active) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
async def _propagate_parent_status(db: AsyncSession, tenant_id: uuid.UUID, task: Task) -> None:
|
||||||
|
"""Propagate status changes to the parent task.
|
||||||
|
|
||||||
|
When all children of a parent are done, the parent is moved to ``review``
|
||||||
|
(unless it is already done/cancelled). When any child is blocked, the
|
||||||
|
parent is moved to ``blocked``. Otherwise the parent is set to
|
||||||
|
``in_progress`` if it was ``open``.
|
||||||
|
"""
|
||||||
|
if not task.parent_task_id:
|
||||||
|
return
|
||||||
|
parent = await _get_task(db, tenant_id, task.parent_task_id)
|
||||||
|
if parent is None or parent.status in DONE_STATUSES or parent.status == "cancelled":
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Task).where(
|
||||||
|
Task.tenant_id == tenant_id,
|
||||||
|
Task.parent_task_id == parent.id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
children = result.scalars().all()
|
||||||
|
if not children:
|
||||||
|
return
|
||||||
|
|
||||||
|
active = [c for c in children if c.status != "cancelled"]
|
||||||
|
if not active:
|
||||||
|
parent.status = "review"
|
||||||
|
elif any(c.status == "blocked" for c in active):
|
||||||
|
parent.status = "blocked"
|
||||||
|
elif all(c.status in DONE_STATUSES for c in active):
|
||||||
|
parent.status = "review"
|
||||||
|
elif parent.status == "open":
|
||||||
|
parent.status = "in_progress"
|
||||||
|
|
||||||
|
await _recompute_progress(db, tenant_id, parent)
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_success_criteria(task: Task) -> bool:
|
||||||
|
"""Evaluate structured success criteria for a goal.
|
||||||
|
|
||||||
|
Supported criteria shapes (JSONB):
|
||||||
|
- ``{"all_done": true}`` — all child tasks done
|
||||||
|
- ``{"criteria": [{"field": "status", "op": "eq", "value": "done"}]}``
|
||||||
|
— every criterion must match the task's own fields
|
||||||
|
- ``{"criteria": [{"field": "progress", "op": "gte", "value": 100}]}``
|
||||||
|
- ``{"criteria": [{"field": "target_date", "op": "lte", "value": "<iso>"}]}``
|
||||||
|
|
||||||
|
Returns True when the criteria are met (or when no criteria are set).
|
||||||
|
"""
|
||||||
|
criteria = task.success_criteria
|
||||||
|
if not criteria:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if criteria.get("all_done"):
|
||||||
|
return task.progress >= 100
|
||||||
|
|
||||||
|
raw = criteria.get("criteria") or []
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _field_value(field: str) -> Any:
|
||||||
|
if field == "status":
|
||||||
|
return task.status
|
||||||
|
if field == "progress":
|
||||||
|
return task.progress or 0
|
||||||
|
if field == "target_date":
|
||||||
|
return task.target_date
|
||||||
|
if field == "due_date":
|
||||||
|
return task.due_date
|
||||||
|
return getattr(task, field, None)
|
||||||
|
|
||||||
|
for item in raw:
|
||||||
|
field = item.get("field")
|
||||||
|
op = item.get("op", "eq")
|
||||||
|
value = item.get("value")
|
||||||
|
actual = _field_value(field)
|
||||||
|
if op == "eq":
|
||||||
|
if actual != value:
|
||||||
|
return False
|
||||||
|
elif op == "neq":
|
||||||
|
if actual == value:
|
||||||
|
return False
|
||||||
|
elif op == "gte":
|
||||||
|
if actual is None or actual < value:
|
||||||
|
return False
|
||||||
|
elif op == "lte":
|
||||||
|
if actual is None or actual > value:
|
||||||
|
return False
|
||||||
|
elif op == "contains":
|
||||||
|
if value not in (actual or []):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
@@ -41,6 +194,12 @@ async def list_tasks(
|
|||||||
assigned_to: str | None = None,
|
assigned_to: str | None = None,
|
||||||
contact_id: str | None = None,
|
contact_id: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
assignee_type: str | None = None,
|
||||||
|
assignee_id: str | None = None,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
|
task_type: str | None = None,
|
||||||
user_id: uuid.UUID | None = None,
|
user_id: uuid.UUID | None = None,
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -60,6 +219,18 @@ async def list_tasks(
|
|||||||
query = query.where(Task.assigned_to == uuid.UUID(assigned_to))
|
query = query.where(Task.assigned_to == uuid.UUID(assigned_to))
|
||||||
if contact_id:
|
if contact_id:
|
||||||
query = query.where(Task.contact_id == uuid.UUID(contact_id))
|
query = query.where(Task.contact_id == uuid.UUID(contact_id))
|
||||||
|
if entity_type:
|
||||||
|
query = query.where(Task.entity_type == entity_type)
|
||||||
|
if entity_id:
|
||||||
|
query = query.where(Task.entity_id == uuid.UUID(entity_id))
|
||||||
|
if assignee_type:
|
||||||
|
query = query.where(Task.assignee_type == assignee_type)
|
||||||
|
if assignee_id:
|
||||||
|
query = query.where(Task.assignee_id == uuid.UUID(assignee_id))
|
||||||
|
if parent_task_id:
|
||||||
|
query = query.where(Task.parent_task_id == uuid.UUID(parent_task_id))
|
||||||
|
if task_type:
|
||||||
|
query = query.where(Task.task_type == task_type)
|
||||||
if search:
|
if search:
|
||||||
query = query.where(Task.title.ilike(f"%{search}%"))
|
query = query.where(Task.title.ilike(f"%{search}%"))
|
||||||
|
|
||||||
@@ -85,10 +256,7 @@ async def list_tasks(
|
|||||||
|
|
||||||
async def get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> dict[str, Any] | None:
|
async def get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> dict[str, Any] | None:
|
||||||
"""Get a single task by ID."""
|
"""Get a single task by ID."""
|
||||||
result = await db.execute(
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
return _task_to_dict(task)
|
return _task_to_dict(task)
|
||||||
@@ -103,6 +271,32 @@ async def create_task(
|
|||||||
"""Create a new task."""
|
"""Create a new task."""
|
||||||
from app.core.hooks import do_action
|
from app.core.hooks import do_action
|
||||||
await do_action("task.before_create", data, db=db, tenant_id=tenant_id, user_id=user_id)
|
await do_action("task.before_create", data, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||||
|
|
||||||
|
# Resolve polymorphic assignee, mirroring legacy fields for compatibility.
|
||||||
|
assignee_type = data.get("assignee_type", "user")
|
||||||
|
assignee_id = data.get("assignee_id")
|
||||||
|
assigned_to = data.get("assigned_to")
|
||||||
|
if assignee_type == "user" and assignee_id and not assigned_to:
|
||||||
|
assigned_to = assignee_id
|
||||||
|
if assignee_type == "user" and assigned_to and not assignee_id:
|
||||||
|
assignee_id = assigned_to
|
||||||
|
|
||||||
|
# Resolve polymorphic entity link, mirroring legacy contact_id.
|
||||||
|
entity_type = data.get("entity_type")
|
||||||
|
entity_id = data.get("entity_id")
|
||||||
|
contact_id = data.get("contact_id")
|
||||||
|
if entity_type == "contact" and entity_id and not contact_id:
|
||||||
|
contact_id = entity_id
|
||||||
|
if not entity_type and contact_id:
|
||||||
|
entity_type = "contact"
|
||||||
|
entity_id = contact_id
|
||||||
|
|
||||||
|
# Resolve polymorphic creator.
|
||||||
|
creator_type = data.get("creator_type", "user")
|
||||||
|
creator_id = data.get("creator_id")
|
||||||
|
if creator_type == "user" and not creator_id:
|
||||||
|
creator_id = user_id
|
||||||
|
|
||||||
task = Task(
|
task = Task(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
@@ -110,13 +304,33 @@ async def create_task(
|
|||||||
status=data.get("status", "open"),
|
status=data.get("status", "open"),
|
||||||
priority=data.get("priority", "medium"),
|
priority=data.get("priority", "medium"),
|
||||||
due_date=data.get("due_date"),
|
due_date=data.get("due_date"),
|
||||||
assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None,
|
assigned_to=uuid.UUID(assigned_to) if assigned_to else None,
|
||||||
contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None,
|
contact_id=uuid.UUID(contact_id) if contact_id else None,
|
||||||
|
assignee_type=assignee_type,
|
||||||
|
assignee_id=uuid.UUID(assignee_id) if assignee_id else None,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=uuid.UUID(entity_id) if entity_id else None,
|
||||||
|
creator_type=creator_type,
|
||||||
|
creator_id=uuid.UUID(creator_id) if creator_id else None,
|
||||||
|
parent_task_id=uuid.UUID(data["parent_task_id"]) if data.get("parent_task_id") else None,
|
||||||
|
depends_on=[str(d) for d in (data.get("depends_on") or [])],
|
||||||
|
task_type=data.get("task_type", "todo"),
|
||||||
|
success_criteria=data.get("success_criteria"),
|
||||||
|
target_date=data.get("target_date"),
|
||||||
|
progress=data.get("progress", 0),
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
)
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# Recompute parent progress/status when creating a subtask.
|
||||||
|
if task.parent_task_id:
|
||||||
|
parent = await _get_task(db, tenant_id, task.parent_task_id)
|
||||||
|
if parent is not None:
|
||||||
|
await _recompute_progress(db, tenant_id, parent)
|
||||||
|
await _propagate_parent_status(db, tenant_id, task)
|
||||||
|
|
||||||
snapshot = _task_to_dict(task)
|
snapshot = _task_to_dict(task)
|
||||||
|
|
||||||
# Record history (D-PLUG)
|
# Record history (D-PLUG)
|
||||||
@@ -135,6 +349,9 @@ async def create_task(
|
|||||||
'user_id': str(user_id),
|
'user_id': str(user_id),
|
||||||
'title': task.title,
|
'title': task.title,
|
||||||
'assigned_to': str(task.assigned_to) if task.assigned_to else None,
|
'assigned_to': str(task.assigned_to) if task.assigned_to else None,
|
||||||
|
'assignee_type': task.assignee_type,
|
||||||
|
'assignee_id': str(task.assignee_id) if task.assignee_id else None,
|
||||||
|
'task_type': task.task_type,
|
||||||
})
|
})
|
||||||
|
|
||||||
return _task_to_dict(task)
|
return _task_to_dict(task)
|
||||||
@@ -147,10 +364,7 @@ async def update_task(
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update a task."""
|
"""Update a task."""
|
||||||
result = await db.execute(
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -172,9 +386,42 @@ async def update_task(
|
|||||||
task.assigned_to = uuid.UUID(data["assigned_to"]) if data["assigned_to"] else None
|
task.assigned_to = uuid.UUID(data["assigned_to"]) if data["assigned_to"] else None
|
||||||
if "contact_id" in data:
|
if "contact_id" in data:
|
||||||
task.contact_id = uuid.UUID(data["contact_id"]) if data["contact_id"] else None
|
task.contact_id = uuid.UUID(data["contact_id"]) if data["contact_id"] else None
|
||||||
|
# F.14 polymorphic fields
|
||||||
|
if "assignee_type" in data and data["assignee_type"] is not None:
|
||||||
|
task.assignee_type = data["assignee_type"]
|
||||||
|
if "assignee_id" in data:
|
||||||
|
task.assignee_id = uuid.UUID(data["assignee_id"]) if data["assignee_id"] else None
|
||||||
|
if "entity_type" in data:
|
||||||
|
task.entity_type = data["entity_type"]
|
||||||
|
if "entity_id" in data:
|
||||||
|
task.entity_id = uuid.UUID(data["entity_id"]) if data["entity_id"] else None
|
||||||
|
if "creator_type" in data and data["creator_type"] is not None:
|
||||||
|
task.creator_type = data["creator_type"]
|
||||||
|
if "creator_id" in data:
|
||||||
|
task.creator_id = uuid.UUID(data["creator_id"]) if data["creator_id"] else None
|
||||||
|
if "parent_task_id" in data:
|
||||||
|
task.parent_task_id = uuid.UUID(data["parent_task_id"]) if data["parent_task_id"] else None
|
||||||
|
if "depends_on" in data:
|
||||||
|
task.depends_on = [str(d) for d in (data["depends_on"] or [])]
|
||||||
|
if "task_type" in data and data["task_type"] is not None:
|
||||||
|
task.task_type = data["task_type"]
|
||||||
|
if "success_criteria" in data:
|
||||||
|
task.success_criteria = data["success_criteria"]
|
||||||
|
if "target_date" in data:
|
||||||
|
task.target_date = data["target_date"]
|
||||||
|
if "progress" in data and data["progress"] is not None:
|
||||||
|
task.progress = data["progress"]
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(task)
|
await db.refresh(task)
|
||||||
|
|
||||||
|
# Recompute parent progress/status after a child update.
|
||||||
|
if task.parent_task_id:
|
||||||
|
parent = await _get_task(db, tenant_id, task.parent_task_id)
|
||||||
|
if parent is not None:
|
||||||
|
await _recompute_progress(db, tenant_id, parent)
|
||||||
|
await _propagate_parent_status(db, tenant_id, task)
|
||||||
|
|
||||||
snapshot_after = _task_to_dict(task)
|
snapshot_after = _task_to_dict(task)
|
||||||
# Compute changes diff (D-PLUG)
|
# Compute changes diff (D-PLUG)
|
||||||
changes: dict = {}
|
changes: dict = {}
|
||||||
@@ -193,10 +440,7 @@ async def update_task(
|
|||||||
|
|
||||||
async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> bool:
|
async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> bool:
|
||||||
"""Soft-delete a task."""
|
"""Soft-delete a task."""
|
||||||
result = await db.execute(
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return False
|
return False
|
||||||
from app.core.hooks import do_action
|
from app.core.hooks import do_action
|
||||||
@@ -217,16 +461,21 @@ async def assign_task(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
task_id: uuid.UUID,
|
task_id: uuid.UUID,
|
||||||
assigned_to: uuid.UUID,
|
assigned_to: uuid.UUID | None = None,
|
||||||
|
*,
|
||||||
|
assignee_type: str = "user",
|
||||||
|
assignee_id: uuid.UUID | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Assign a task to a user."""
|
"""Assign a task to a user, agent, or group (polymorphic)."""
|
||||||
result = await db.execute(
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
task.assigned_to = assigned_to
|
if assignee_id is None and assigned_to is not None:
|
||||||
|
assignee_id = assigned_to
|
||||||
|
task.assignee_type = assignee_type
|
||||||
|
task.assignee_id = assignee_id
|
||||||
|
# Mirror legacy field for user assignment.
|
||||||
|
task.assigned_to = assignee_id if assignee_type == "user" else None
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return _task_to_dict(task)
|
return _task_to_dict(task)
|
||||||
|
|
||||||
@@ -237,21 +486,127 @@ async def update_task_status(
|
|||||||
task_id: uuid.UUID,
|
task_id: uuid.UUID,
|
||||||
new_status: str,
|
new_status: str,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update task status."""
|
"""Update task status and propagate to parent + evaluate goal criteria."""
|
||||||
result = await db.execute(
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
task.status = new_status
|
task.status = new_status
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# Recompute parent progress/status.
|
||||||
|
if task.parent_task_id:
|
||||||
|
parent = await _get_task(db, tenant_id, task.parent_task_id)
|
||||||
|
if parent is not None:
|
||||||
|
await _recompute_progress(db, tenant_id, parent)
|
||||||
|
await _propagate_parent_status(db, tenant_id, task)
|
||||||
|
|
||||||
|
# Goal completion: when all success criteria are met, mark the goal done.
|
||||||
|
if task.task_type == "goal" and new_status != "done":
|
||||||
|
if await _evaluate_success_criteria(task):
|
||||||
|
task.status = "done"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
if new_status == "done":
|
if new_status == "done":
|
||||||
from app.core.outbox import enqueue_outbox_event
|
from app.core.outbox import enqueue_outbox_event
|
||||||
await enqueue_outbox_event(db, tenant_id, "task.completed", {"task_id": str(task.id), "tenant_id": str(tenant_id), "title": task.title, "assigned_to": str(task.assigned_to) if task.assigned_to else None}, aggregate_type="task", aggregate_id=task.id)
|
await enqueue_outbox_event(db, tenant_id, "task.completed", {"task_id": str(task.id), "tenant_id": str(tenant_id), "title": task.title, "assigned_to": str(task.assigned_to) if task.assigned_to else None}, aggregate_type="task", aggregate_id=task.id)
|
||||||
return _task_to_dict(task)
|
return _task_to_dict(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_subtask(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
parent_task_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Create a subtask under a parent task."""
|
||||||
|
parent = await _get_task(db, tenant_id, parent_task_id)
|
||||||
|
if parent is None:
|
||||||
|
return None
|
||||||
|
data = dict(data)
|
||||||
|
data["parent_task_id"] = str(parent_task_id)
|
||||||
|
return await create_task(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_subtasks(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
parent_task_id: uuid.UUID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List subtasks for a parent task."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Task).where(
|
||||||
|
Task.tenant_id == tenant_id,
|
||||||
|
Task.parent_task_id == parent_task_id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
).order_by(Task.created_at.asc())
|
||||||
|
)
|
||||||
|
return [_task_to_dict(t) for t in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_dependency(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
task_id: uuid.UUID,
|
||||||
|
depends_on: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Add a dependency (this task depends on another task)."""
|
||||||
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
|
if task is None:
|
||||||
|
return None
|
||||||
|
dep = await _get_task(db, tenant_id, depends_on)
|
||||||
|
if dep is None:
|
||||||
|
return None
|
||||||
|
current = [str(d) for d in (task.depends_on or [])]
|
||||||
|
if str(depends_on) not in current:
|
||||||
|
current.append(str(depends_on))
|
||||||
|
task.depends_on = current
|
||||||
|
await db.flush()
|
||||||
|
return _task_to_dict(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_dependency(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
task_id: uuid.UUID,
|
||||||
|
depends_on: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Remove a dependency from a task."""
|
||||||
|
task = await _get_task(db, tenant_id, task_id)
|
||||||
|
if task is None:
|
||||||
|
return None
|
||||||
|
current = [str(d) for d in (task.depends_on or [])]
|
||||||
|
if str(depends_on) in current:
|
||||||
|
current.remove(str(depends_on))
|
||||||
|
task.depends_on = current
|
||||||
|
await db.flush()
|
||||||
|
return _task_to_dict(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def decompose_goal(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
goal_id: uuid.UUID,
|
||||||
|
subtasks: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Decompose a goal into subtasks (milestones/todos)."""
|
||||||
|
goal = await _get_task(db, tenant_id, goal_id)
|
||||||
|
if goal is None:
|
||||||
|
return None
|
||||||
|
if goal.task_type != "goal":
|
||||||
|
goal.task_type = "goal"
|
||||||
|
created: list[dict[str, Any]] = []
|
||||||
|
for item in subtasks:
|
||||||
|
data = dict(item)
|
||||||
|
data["parent_task_id"] = str(goal_id)
|
||||||
|
data.setdefault("task_type", "milestone" if item.get("milestone") else "todo")
|
||||||
|
created.append(await create_task(db, tenant_id, user_id, data))
|
||||||
|
await _recompute_progress(db, tenant_id, goal)
|
||||||
|
await _propagate_parent_status(db, tenant_id, goal)
|
||||||
|
return {"goal": _task_to_dict(goal), "subtasks": created}
|
||||||
|
|
||||||
|
|
||||||
async def get_due_tasks(
|
async def get_due_tasks(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
|||||||
Reference in New Issue
Block a user