Adds the backing store for the FlowPilot unified session surface, per the FLOWPILOT-MIGRATION.md Phase 1 deliverable. Descends from production head 074 (add_network_diagrams_table). New tables (all tenant-scoped, all RLS-enabled + forced): - session_facts — "What we know" facts. source_ref is a polymorphic pointer to a task-lane item inside ai_sessions.pending_task_lane (no DB-level FK; integrity enforced at service layer per Section 4.2 of the design doc). Soft-delete via deleted_at; active-facts partial index excludes deleted rows. - session_suggested_fixes — AI-proposed resolutions. One active per session at a time (supersession tracked via superseded_at; partial index on (session_id) WHERE superseded_at IS NULL powers the "find active fix" query). - draft_templates — scripts pending post-resolve templatization. Partial index on (account_id) WHERE status='pending' supports the "N scripts ready to review" Script Library badge. - account_settings — new per-account table with JSONB preferences grab-bag. Rows created lazily on first write; get_setting returns default when no row exists. Column additions on ai_sessions: - resolution_note_markdown / posted_at / external_id - escalation_package_markdown / posted_at / external_id - state_version (INTEGER NOT NULL DEFAULT 0) — incremented atomically by any write that invalidates the resolution note preview cache per Section 5.5. Phase 3 consumes this. Column additions on script_templates: - source_session_id, source_user_id, source_ticket_ref — powers the "generated from CW #X · resolved by Y · used N times" provenance chip in the Script Library. RLS pattern matches the repo convention (074 / network_diagrams is the nearest template): ENABLE + FORCE, USING + WITH CHECK on `account_id = app.current_account_id`. Downgrade is reversible — drops in the inverse order of creation so FK dependencies unwind. No runtime verification from code-server; migration apply + downgrade will be verified on the new dev environment per the standing deferral. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""FlowPilot migration Phase 1 — schema for the unified session surface.
|
|
|
|
Revision ID: f07010f17b01
|
|
Revises: 074
|
|
Create Date: 2026-04-17
|
|
|
|
Creates the backing store for the FlowPilot unified session surface:
|
|
|
|
- `session_facts` — "What we know" facts, keyed to a session, with a polymorphic
|
|
`source_ref` pointing at a task-lane item inside `ai_sessions.pending_task_lane`
|
|
(no DB-level FK; integrity enforced at the service layer per the design doc).
|
|
- `session_suggested_fixes` — AI-proposed resolution paths. Only one active
|
|
(`superseded_at IS NULL`) per session at a time.
|
|
- `draft_templates` — scripts pending post-resolve templatization
|
|
(Option 2 in the three-option dialog).
|
|
- `account_settings` — new per-account key/value settings table with a JSONB
|
|
`preferences` grab-bag. Rows are created lazily on first write.
|
|
- Column additions to `ai_sessions` — resolution/escalation markdown + external IDs,
|
|
plus `state_version` (incremented by any write that invalidates the resolution
|
|
note preview cache).
|
|
- Column additions to `script_templates` — provenance fields for templates
|
|
promoted from draft_templates.
|
|
|
|
All four new tenant-scoped tables have RLS enabled + forced with a
|
|
`tenant_isolation` policy matching the repo pattern (USING + WITH CHECK on
|
|
`account_id = app.current_account_id`). Downgrade is reversible: drops in the
|
|
inverse order of creation.
|
|
|
|
Chained from `074` (add_network_diagrams_table) per the single-head state of
|
|
production; the other local heads on feat/flowpilot-migration are branch
|
|
artifacts not present in production.
|
|
"""
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
|
|
revision = "f07010f17b01"
|
|
down_revision = "074"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
_CURRENT_ACCOUNT = (
|
|
"COALESCE("
|
|
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
|
"'00000000-0000-0000-0000-000000000000'"
|
|
")::uuid"
|
|
)
|
|
|
|
|
|
def upgrade() -> None:
|
|
# ── ai_sessions: resolution / escalation columns + state_version ───────
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("resolution_note_markdown", sa.Text(), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("resolution_note_posted_at", sa.DateTime(timezone=True), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("resolution_note_external_id", sa.String(128), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("escalation_package_markdown", sa.Text(), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("escalation_package_posted_at", sa.DateTime(timezone=True), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column("escalation_package_external_id", sa.String(128), nullable=True),
|
|
)
|
|
op.add_column(
|
|
"ai_sessions",
|
|
sa.Column(
|
|
"state_version",
|
|
sa.Integer(),
|
|
nullable=False,
|
|
server_default=sa.text("0"),
|
|
),
|
|
)
|
|
|
|
# ── script_templates: provenance for post-resolve promotion ────────────
|
|
op.add_column(
|
|
"script_templates",
|
|
sa.Column(
|
|
"source_session_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("ai_sessions.id"),
|
|
nullable=True,
|
|
),
|
|
)
|
|
op.add_column(
|
|
"script_templates",
|
|
sa.Column(
|
|
"source_user_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id"),
|
|
nullable=True,
|
|
),
|
|
)
|
|
op.add_column(
|
|
"script_templates",
|
|
sa.Column("source_ticket_ref", sa.String(64), nullable=True),
|
|
)
|
|
|
|
# ── session_facts ──────────────────────────────────────────────────────
|
|
op.create_table(
|
|
"session_facts",
|
|
sa.Column(
|
|
"id",
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()"),
|
|
),
|
|
sa.Column(
|
|
"session_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"account_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("accounts.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("text", sa.Text(), nullable=False),
|
|
sa.Column("source_type", sa.String(32), nullable=False),
|
|
# `source_ref` is a polymorphic pointer to a task-lane item inside
|
|
# ai_sessions.pending_task_lane JSON, NOT a FK to any table.
|
|
# Integrity enforced at the service layer per Section 4.2 of the
|
|
# migration design doc.
|
|
sa.Column("source_ref", UUID(as_uuid=True), nullable=True),
|
|
sa.Column("source_summary", sa.Text(), nullable=True),
|
|
sa.Column(
|
|
"created_by",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.CheckConstraint(
|
|
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
|
|
name="ck_session_facts_source_type",
|
|
),
|
|
)
|
|
# Active-facts-per-session; partial index excludes soft-deleted rows.
|
|
op.create_index(
|
|
"idx_session_facts_session",
|
|
"session_facts",
|
|
["session_id"],
|
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
|
)
|
|
op.create_index(
|
|
"idx_session_facts_account",
|
|
"session_facts",
|
|
["account_id"],
|
|
)
|
|
op.execute("ALTER TABLE session_facts ENABLE ROW LEVEL SECURITY")
|
|
op.execute("ALTER TABLE session_facts FORCE ROW LEVEL SECURITY")
|
|
op.execute(f"""
|
|
CREATE POLICY tenant_isolation ON session_facts
|
|
USING (account_id = {_CURRENT_ACCOUNT})
|
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
|
""")
|
|
|
|
# ── session_suggested_fixes ────────────────────────────────────────────
|
|
op.create_table(
|
|
"session_suggested_fixes",
|
|
sa.Column(
|
|
"id",
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()"),
|
|
),
|
|
sa.Column(
|
|
"session_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"account_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("accounts.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("title", sa.String(200), nullable=False),
|
|
sa.Column("description", sa.Text(), nullable=False),
|
|
sa.Column("confidence_pct", sa.Integer(), nullable=False),
|
|
sa.Column(
|
|
"script_template_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("script_templates.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column("ai_drafted_script", sa.Text(), nullable=True),
|
|
sa.Column("ai_drafted_parameters", JSONB(), nullable=True),
|
|
sa.Column("user_decision", sa.String(32), nullable=True),
|
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.CheckConstraint(
|
|
"confidence_pct BETWEEN 0 AND 100",
|
|
name="ck_session_suggested_fixes_confidence_pct",
|
|
),
|
|
sa.CheckConstraint(
|
|
"user_decision IS NULL OR user_decision IN ("
|
|
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
|
name="ck_session_suggested_fixes_user_decision",
|
|
),
|
|
)
|
|
# Only-one-active-per-session is enforced by service-layer supersession;
|
|
# this partial index serves the "find active fix" query.
|
|
op.create_index(
|
|
"idx_session_suggested_fixes_session_active",
|
|
"session_suggested_fixes",
|
|
["session_id"],
|
|
postgresql_where=sa.text("superseded_at IS NULL"),
|
|
)
|
|
op.execute("ALTER TABLE session_suggested_fixes ENABLE ROW LEVEL SECURITY")
|
|
op.execute("ALTER TABLE session_suggested_fixes FORCE ROW LEVEL SECURITY")
|
|
op.execute(f"""
|
|
CREATE POLICY tenant_isolation ON session_suggested_fixes
|
|
USING (account_id = {_CURRENT_ACCOUNT})
|
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
|
""")
|
|
|
|
# ── draft_templates ────────────────────────────────────────────────────
|
|
op.create_table(
|
|
"draft_templates",
|
|
sa.Column(
|
|
"id",
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()"),
|
|
),
|
|
sa.Column(
|
|
"account_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("accounts.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"source_session_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("ai_sessions.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"source_user_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("script_body", sa.Text(), nullable=False),
|
|
sa.Column("proposed_parameters", JSONB(), nullable=False),
|
|
sa.Column("proposed_name", sa.String(200), nullable=True),
|
|
sa.Column(
|
|
"proposed_category_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("script_categories.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column(
|
|
"status",
|
|
sa.String(32),
|
|
nullable=False,
|
|
server_default=sa.text("'pending'"),
|
|
),
|
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column(
|
|
"promoted_template_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("script_templates.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.CheckConstraint(
|
|
"status IN ('pending', 'accepted', 'rejected')",
|
|
name="ck_draft_templates_status",
|
|
),
|
|
)
|
|
# Supports the Script Library "N scripts ready to review" badge.
|
|
op.create_index(
|
|
"idx_draft_templates_account_pending",
|
|
"draft_templates",
|
|
["account_id"],
|
|
postgresql_where=sa.text("status = 'pending'"),
|
|
)
|
|
op.execute("ALTER TABLE draft_templates ENABLE ROW LEVEL SECURITY")
|
|
op.execute("ALTER TABLE draft_templates FORCE ROW LEVEL SECURITY")
|
|
op.execute(f"""
|
|
CREATE POLICY tenant_isolation ON draft_templates
|
|
USING (account_id = {_CURRENT_ACCOUNT})
|
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
|
""")
|
|
|
|
# ── account_settings ───────────────────────────────────────────────────
|
|
# One row per account, created lazily on first write. The `preferences`
|
|
# JSONB is a grab-bag for simple settings (e.g. templatize_prompt_enabled).
|
|
# Settings graduate to typed columns via future migrations when they meet
|
|
# the promotion criteria in Section 4.6 of the design doc (hot path /
|
|
# validation / joins).
|
|
op.create_table(
|
|
"account_settings",
|
|
sa.Column(
|
|
"account_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
sa.Column(
|
|
"preferences",
|
|
JSONB(),
|
|
nullable=False,
|
|
server_default=sa.text("'{}'::jsonb"),
|
|
),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
)
|
|
op.execute("ALTER TABLE account_settings ENABLE ROW LEVEL SECURITY")
|
|
op.execute("ALTER TABLE account_settings FORCE ROW LEVEL SECURITY")
|
|
op.execute(f"""
|
|
CREATE POLICY tenant_isolation ON account_settings
|
|
USING (account_id = {_CURRENT_ACCOUNT})
|
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
|
""")
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Drop in reverse order so FK dependencies unwind cleanly.
|
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON account_settings")
|
|
op.execute("ALTER TABLE account_settings DISABLE ROW LEVEL SECURITY")
|
|
op.drop_table("account_settings")
|
|
|
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON draft_templates")
|
|
op.execute("ALTER TABLE draft_templates DISABLE ROW LEVEL SECURITY")
|
|
op.drop_index("idx_draft_templates_account_pending", table_name="draft_templates")
|
|
op.drop_table("draft_templates")
|
|
|
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_suggested_fixes")
|
|
op.execute("ALTER TABLE session_suggested_fixes DISABLE ROW LEVEL SECURITY")
|
|
op.drop_index(
|
|
"idx_session_suggested_fixes_session_active",
|
|
table_name="session_suggested_fixes",
|
|
)
|
|
op.drop_table("session_suggested_fixes")
|
|
|
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_facts")
|
|
op.execute("ALTER TABLE session_facts DISABLE ROW LEVEL SECURITY")
|
|
op.drop_index("idx_session_facts_account", table_name="session_facts")
|
|
op.drop_index("idx_session_facts_session", table_name="session_facts")
|
|
op.drop_table("session_facts")
|
|
|
|
op.drop_column("script_templates", "source_ticket_ref")
|
|
op.drop_column("script_templates", "source_user_id")
|
|
op.drop_column("script_templates", "source_session_id")
|
|
|
|
op.drop_column("ai_sessions", "state_version")
|
|
op.drop_column("ai_sessions", "escalation_package_external_id")
|
|
op.drop_column("ai_sessions", "escalation_package_posted_at")
|
|
op.drop_column("ai_sessions", "escalation_package_markdown")
|
|
op.drop_column("ai_sessions", "resolution_note_external_id")
|
|
op.drop_column("ai_sessions", "resolution_note_posted_at")
|
|
op.drop_column("ai_sessions", "resolution_note_markdown")
|