144 lines
4.5 KiB
Python
144 lines
4.5 KiB
Python
"""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/<uuid>/<user-relative-dir>.
|
|
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")
|