"""Unified Task System (F.14). Adds polymorphic assignment/entity/creator fields, subtasks, dependencies, goals/milestones and agent-subtask support to the tasks table. Migrates legacy ``contact_id``/``assigned_to`` values into the polymorphic fields and migrates existing ``agent_subtasks`` rows into tasks with ``task_type='agent_subtask'``. Revision ID: 0124 Revises: 0123 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID revision = "0124" down_revision = "0123" branch_labels = None depends_on = None def _table_exists(conn, table_name: str) -> bool: """True when the table exists (dual-path convergence, Gate B). On a fresh install the tasks plugin SQL migration has not run yet when Alembic reaches this revision — skip instead of failing. The plugin-side convergence migration adds the same columns/indexes. The legacy-data backfills below only matter for pre-existing rows and are correctly empty on a fresh install. """ row = conn.execute( sa.text("SELECT to_regclass(:tname) IS NOT NULL"), {"tname": f"public.{table_name}"}, ).scalar() return bool(row) def upgrade() -> None: conn = op.get_bind() if not _table_exists(conn, "tasks"): return # ── Add new columns to tasks ──────────────────────────────────────────── op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user")) op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("entity_type", sa.String(80), nullable=True)) op.add_column("tasks", sa.Column("entity_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("creator_type", sa.String(20), nullable=False, server_default="user")) op.add_column("tasks", sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("parent_task_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("depends_on", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb"))) op.add_column("tasks", sa.Column("task_type", sa.String(30), nullable=False, server_default="todo")) op.add_column("tasks", sa.Column("success_criteria", JSONB, nullable=True)) op.add_column("tasks", sa.Column("target_date", sa.DateTime(timezone=True), nullable=True)) op.add_column("tasks", sa.Column("progress", sa.Integer(), nullable=False, server_default="0")) # ── Migrate legacy data into polymorphic fields ───────────────────────── # contact_id → entity_type='contact' + entity_id op.execute( """ UPDATE tasks SET entity_type = 'contact', entity_id = contact_id WHERE contact_id IS NOT NULL AND entity_type IS NULL """ ) # assigned_to → assignee_type='user' + assignee_id op.execute( """ UPDATE tasks SET assignee_type = 'user', assignee_id = assigned_to WHERE assigned_to IS NOT NULL AND assignee_id IS NULL """ ) # created_by → creator_type='user' + creator_id op.execute( """ UPDATE tasks SET creator_type = 'user', creator_id = created_by WHERE created_by IS NOT NULL AND creator_id IS NULL """ ) # ── Migrate AgentSubtask rows into tasks ──────────────────────────────── op.execute( """ INSERT INTO tasks ( id, tenant_id, title, description, status, priority, assignee_type, assignee_id, entity_type, entity_id, creator_type, creator_id, task_type, depends_on, progress, created_at, updated_at ) SELECT asub.id, asub.tenant_id, asub.task_description, asub.task_description, asub.status, 'medium', 'agent', asub.child_agent_id, 'agent', asub.parent_agent_id, 'agent', asub.parent_agent_id, 'agent_subtask', '[]'::jsonb, 0, asub.created_at, asub.updated_at FROM agent_subtasks asub WHERE NOT EXISTS ( SELECT 1 FROM tasks t WHERE t.id = asub.id ) """ ) # ── Indexes ───────────────────────────────────────────────────────────── op.create_index("ix_tasks_tenant_entity", "tasks", ["tenant_id", "entity_type", "entity_id"]) op.create_index("ix_tasks_tenant_assignee", "tasks", ["tenant_id", "assignee_type", "assignee_id"]) op.create_index("ix_tasks_tenant_parent", "tasks", ["tenant_id", "parent_task_id"]) op.create_index("ix_tasks_tenant_type", "tasks", ["tenant_id", "task_type"]) op.create_foreign_key( "fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"], ondelete="CASCADE", ) def downgrade() -> None: op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey") op.drop_index("ix_tasks_tenant_type", table_name="tasks") op.drop_index("ix_tasks_tenant_parent", table_name="tasks") op.drop_index("ix_tasks_tenant_assignee", table_name="tasks") op.drop_index("ix_tasks_tenant_entity", table_name="tasks") op.drop_column("tasks", "progress") op.drop_column("tasks", "target_date") op.drop_column("tasks", "success_criteria") op.drop_column("tasks", "task_type") op.drop_column("tasks", "depends_on") op.drop_column("tasks", "parent_task_id") op.drop_column("tasks", "creator_id") op.drop_column("tasks", "creator_type") op.drop_column("tasks", "entity_id") op.drop_column("tasks", "entity_type") op.drop_column("tasks", "assignee_id") op.drop_column("tasks", "assignee_type")