"""Add stable artifact identity and lifecycle metadata. Revision ID: 0028 Revises: 0027 Create Date: 2026-08-12 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0028" down_revision: Union[str, None] = "0027" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "artifacts", sa.Column( "artifact_id", postgresql.UUID(as_uuid=True), nullable=False, ), sa.Column( "user_id", postgresql.UUID(as_uuid=True), nullable=False, ), sa.Column( "origin_task_id", postgresql.UUID(as_uuid=True), nullable=True, ), sa.Column( "copied_from_artifact_id", postgresql.UUID(as_uuid=True), nullable=True, ), sa.Column("current_path", sa.Text(), nullable=False), sa.Column("label", sa.Text(), server_default="", nullable=False), sa.Column("media_type", sa.Text(), nullable=True), sa.Column("size_bytes", sa.BigInteger(), nullable=True), sa.Column("content_sha256", sa.Text(), nullable=True), sa.Column("status", sa.Text(), server_default="active", nullable=False), sa.Column("trash_path", sa.Text(), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint( "status IN ('active', 'deleted')", name="ck_artifacts_status", ), sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]), sa.ForeignKeyConstraint( ["origin_task_id"], ["tasks.task_id"], ondelete="SET NULL", ), sa.ForeignKeyConstraint( ["copied_from_artifact_id"], ["artifacts.artifact_id"], ondelete="SET NULL", ), sa.PrimaryKeyConstraint("artifact_id"), ) op.create_index( "ix_artifacts_user_status_path", "artifacts", ["user_id", "status", "current_path"], ) op.create_index( "ix_artifacts_origin_task", "artifacts", ["origin_task_id"], ) op.create_index( "uq_artifacts_active_user_path", "artifacts", ["user_id", "current_path"], unique=True, postgresql_where=sa.text("status = 'active'"), ) # Backfill legacy structured refs without rewriting append-only message metadata. # working_dir is stored as workspace/users//. op.execute( r""" INSERT INTO artifacts ( artifact_id, user_id, origin_task_id, current_path, label, status ) SELECT DISTINCT ON (t.user_id, current_path) gen_random_uuid(), t.user_id, t.task_id, current_path, COALESCE(ref->>'label', ''), 'active' FROM tasks AS t JOIN messages AS m ON m.task_id = t.task_id CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(m.artifact_refs) = 'array' THEN m.artifact_refs ELSE '[]'::jsonb END ) AS ref CROSS JOIN LATERAL ( SELECT regexp_replace( t.working_dir, '^workspace/users/' || t.user_id::text || '/', '' ) || '/' || (ref->>'path') AS current_path ) AS resolved WHERE m.artifact_refs IS NOT NULL AND ref->>'scope' = 'working_dir' AND COALESCE(ref->>'path', '') <> '' AND ref->>'path' !~ '(^/|(^|/)\.\.(/|$))' AND strpos(ref->>'path', chr(92)) = 0 ORDER BY t.user_id, current_path, m.created_at ON CONFLICT DO NOTHING """ ) def downgrade() -> None: op.drop_index("uq_artifacts_active_user_path", table_name="artifacts") op.drop_index("ix_artifacts_origin_task", table_name="artifacts") op.drop_index("ix_artifacts_user_status_path", table_name="artifacts") op.drop_table("artifacts")