fix(gate-b): fresh-db install path - conditional guards on plugin-table migrations + dual-path convergence migrations
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-23 21:36:56 +02:00
parent e3fb4728d7
commit ad7c763e59
24 changed files with 433 additions and 116 deletions
@@ -18,7 +18,26 @@ 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 ai_assistant plugin SQL migration has not run
yet when Alembic reaches this revision — skip instead of failing.
The plugin-side migration adds the same columns idempotently.
"""
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, "ai_providers"):
# Fresh-install path: table arrives with the ai_assistant plugin
# migration, which includes these columns.
return
op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown"))
op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud"))
op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none"))
@@ -29,6 +48,9 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "ai_providers"):
return
op.drop_column("ai_providers", "allowed_data_classes")
op.drop_column("ai_providers", "transfer_notice")
op.drop_column("ai_providers", "training_on_customer_data")
@@ -17,7 +17,23 @@ 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 kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision — skip the comm_* parts instead
of failing. The plugin-side migration adds the same column idempotently.
"""
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 _table_exists(conn, "comm_conversations"):
# 1. Add is_system column to comm_conversations
op.add_column(
"comm_conversations",
@@ -130,13 +146,16 @@ def upgrade() -> None:
AND n.deleted_at IS NULL;
""")
# 7. Create legacy view over notifications table for backward compatibility
# 7. Legacy view over the CORE notifications table — exists on both paths
op.execute("DROP VIEW IF EXISTS notifications_legacy")
op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
def downgrade() -> None:
conn = op.get_bind()
op.execute("DROP VIEW IF EXISTS notifications_legacy")
if not _table_exists(conn, "comm_conversations"):
return
op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')")
op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'")
op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'")
+17
View File
@@ -14,7 +14,24 @@ 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 automation plugin SQL migration has not run yet
when Alembic reaches this revision — skip instead of failing. The
plugin-side convergence migration creates the same table.
"""
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, "automation_agent_runs"):
return
op.create_table(
"automation_agent_run_steps",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
@@ -18,7 +18,24 @@ 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 automation 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.
"""
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, "automation_agent_definitions"):
return
op.add_column(
"automation_agent_definitions",
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
@@ -20,7 +20,27 @@ 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))
@@ -12,6 +12,7 @@ Revises: 0126
"""
from alembic import op
import sqlalchemy as sa
revision = "0127"
down_revision = "0126"
@@ -19,7 +20,19 @@ branch_labels = None
depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B)."""
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
# Drop the FK constraint on tasks.contact_id
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
@@ -9,6 +9,7 @@ Revises: 0128
"""
from alembic import op
import sqlalchemy as sa
revision = "0129"
down_revision = "0128"
@@ -27,8 +28,25 @@ TABLES_NEEDING_RLS = [
]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision — skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
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()
for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
op.execute(
f"CREATE POLICY tenant_isolation ON {table} "
@@ -37,6 +55,9 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
@@ -10,6 +10,7 @@ Revises: 0135
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
revision = "0136"
down_revision = "0135"
@@ -29,8 +30,25 @@ TABLES_WITH_BAD_RLS = [
]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision — skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
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()
for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
# Drop old policy with app.tenant_id
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
# Create new policy with app.current_tenant_id
@@ -41,7 +59,10 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute(
f"CREATE POLICY tenant_isolation ON {table} "
@@ -19,7 +19,25 @@ 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 tags 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.
"""
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, "tags"):
return
# parent_id for tree structure (self-referencing FK)
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
op.create_foreign_key(
@@ -35,6 +53,9 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
op.drop_column("tags", "icon")
op.drop_column("tags", "applicable_to")
op.drop_index("ix_tags_parent", table_name="tags")
@@ -18,11 +18,31 @@ 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 report_generator plugin SQL migration has not
run yet when Alembic reaches this revision — skip instead of failing.
The plugin-side convergence migration adds the same column.
"""
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, "report_templates"):
return
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "report_templates"):
return
op.drop_index("ix_report_templates_folder", table_name="report_templates")
op.drop_column("report_templates", "folder_id")
@@ -18,11 +18,31 @@ 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 kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision — skip instead of failing.
The plugin-side migration adds the same column idempotently.
"""
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, "comm_conversations"):
return
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "comm_conversations"):
return
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
op.drop_column("comm_conversations", "folder_id")
@@ -0,0 +1,10 @@
-- Dual-path convergence (Gate B): add compliance columns that Alembic
-- migration 0119 adds on the core path. Idempotent so both install paths
-- converge to the identical schema.
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS region VARCHAR(20) NOT NULL DEFAULT 'unknown';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS hosting_type VARCHAR(30) NOT NULL DEFAULT 'cloud';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS dpa_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS retention_policy TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS training_on_customer_data BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS transfer_notice TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS allowed_data_classes JSONB NOT NULL DEFAULT '[]'::jsonb;
+1 -1
View File
@@ -42,7 +42,7 @@ class AIAssistantPlugin(BasePlugin):
),
],
events=[],
migrations=["0001_initial.sql", "0002_folders_attachments.sql"],
migrations=["0001_initial.sql", "0002_folders_attachments.sql", "0003_sort_order.sql", "0004_compliance_fields.sql"],
permissions=[
"ai:read",
"ai:write",
@@ -0,0 +1,31 @@
-- Dual-path convergence (Gate B): create the ReAct step-tracking table
-- that Alembic migration 0121 creates on the core path, add the Phase-F
-- columns from 0122, and apply the RLS policy from 0129/0136. Idempotent
-- so both install paths converge to the identical schema.
CREATE TABLE IF NOT EXISTS automation_agent_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
agent_run_id UUID NOT NULL REFERENCES automation_agent_runs(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
thought TEXT,
action VARCHAR(255),
action_input JSONB,
observation TEXT,
cost_usd FLOAT NOT NULL DEFAULT 0.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_agent_run_steps_run ON automation_agent_run_steps(tenant_id, agent_run_id);
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS temperature FLOAT NOT NULL DEFAULT 0.3;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 1000;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_steps INTEGER NOT NULL DEFAULT 20;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trace_mode VARCHAR(20) NOT NULL DEFAULT 'standard';
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS skill_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS ai_use_case_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
-- RLS matching migrations 0129 + 0136 (current_tenant_id variant)
ALTER TABLE automation_agent_run_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON automation_agent_run_steps;
CREATE POLICY tenant_isolation ON automation_agent_run_steps
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
+1 -1
View File
@@ -62,7 +62,7 @@ class AutomationPlugin(BasePlugin):
"mail.received",
"workflow.timeout",
],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
permissions=[
"automation:read",
"automation:write",
+7 -3
View File
@@ -25,7 +25,7 @@ class KnowledgePlugin(BasePlugin):
"""Register event-driven extraction hooks on activation."""
await super().on_activate(db, service_container, event_bus)
try:
from app.core.hooks import register_action
from app.core.hooks import get_hook_registry
from app.plugins.builtins.knowledge.services import extract_knowledge
async def on_wiki_create(*args, **kwargs):
article_id = kwargs.get("article_id") or kwargs.get("entity_id")
@@ -41,7 +41,9 @@ class KnowledgePlugin(BasePlugin):
source_type="wiki_article", source_id=uuid.UUID(str(article_id)),
source_title=title, source_text=content,
)
register_action("wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge")
get_hook_registry().register_action(
"wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge"
)
# H-DATA-LIFE: Re-extract when wiki article is updated
async def on_wiki_update(*args, **kwargs):
article_id = kwargs.get("article_id") or kwargs.get("entity_id")
@@ -57,7 +59,9 @@ class KnowledgePlugin(BasePlugin):
source_type="wiki_article", source_id=uuid.UUID(str(article_id)),
source_title=title, source_text=content,
)
register_action("wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge")
get_hook_registry().register_action(
"wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge"
)
logger.info("Registered knowledge extraction hooks")
except Exception:
logger.exception("Failed to register knowledge hooks")
@@ -0,0 +1,7 @@
-- Dual-path convergence (Gate B): add columns that Alembic migrations
-- 0120 (is_system) and 0140 (folder_id) add on the core path. Idempotent
-- so both install paths converge to the identical schema.
ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant_system ON comm_conversations(tenant_id, is_system);
ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS folder_id UUID;
CREATE INDEX IF NOT EXISTS ix_comm_conversations_folder ON comm_conversations(folder_id);
+1 -1
View File
@@ -39,7 +39,7 @@ class KommunikationPlugin(BasePlugin):
"participant.left",
"reaction.added",
],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_system_channel_folders.sql"],
permissions=[
"comm:read",
"comm:write",
@@ -0,0 +1,5 @@
-- Dual-path convergence (Gate B): add the folder_id column that Alembic
-- migration 0139 adds on the core path. Idempotent so both install paths
-- converge to the identical schema.
ALTER TABLE report_templates ADD COLUMN IF NOT EXISTS folder_id UUID;
CREATE INDEX IF NOT EXISTS ix_report_templates_folder ON report_templates(folder_id);
@@ -24,7 +24,7 @@ class ReportGeneratorPlugin(BasePlugin):
),
],
events=["report.requested", "report.generated"],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
menu_items=[
FrontendMenuItem(label_key='nav.reports', label='Berichte', path='/reports', icon='BarChart3', order=70),
@@ -0,0 +1,18 @@
-- Dual-path convergence (Gate B): apply the tag tree structure that
-- Alembic migration 0138 adds on the core path. Idempotent so both install
-- paths converge to the identical schema.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS parent_id UUID;
CREATE INDEX IF NOT EXISTS ix_tags_parent ON tags(parent_id);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_parent_id'
) THEN
ALTER TABLE tags ADD CONSTRAINT fk_tags_parent_id
FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL;
END IF;
END $$;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS applicable_to JSONB;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon VARCHAR(50);
+1 -1
View File
@@ -23,7 +23,7 @@ class TagsPlugin(BasePlugin):
),
],
events=[],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql", "0003_tree_structure.sql"],
permissions=[
"tags:read",
"tags:write",
@@ -0,0 +1,31 @@
-- Dual-path convergence (Gate B): apply the unified task system that
-- Alembic migrations 0124 (columns/indexes/FK) and 0127 (drop legacy FK)
-- create on the core path. Idempotent so both install paths converge to
-- the identical schema. The end state has NO foreign key on contact_id.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_type VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_type VARCHAR(80);
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_type VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_task_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS depends_on JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS task_type VARCHAR(30) NOT NULL DEFAULT 'todo';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS success_criteria JSONB;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS target_date TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS progress INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_entity ON tasks(tenant_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_assignee ON tasks(tenant_id, assignee_type, assignee_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_parent ON tasks(tenant_id, parent_task_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_type ON tasks(tenant_id, task_type);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_tasks_parent_task_id'
) THEN
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_parent_task_id
FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE CASCADE;
END IF;
END $$;
+1 -1
View File
@@ -29,7 +29,7 @@ class TasksPlugin(BasePlugin):
),
],
events=[],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_unified_task_system.sql"],
permissions=[
"tasks:read",
"tasks:write",