feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""Alembic environment for Forge.
|
||||
|
||||
Uses the app's `Base.metadata` as the autogenerate target and the configured
|
||||
`FORGE_DATABASE_URL` (async drivers downgraded to their sync equivalent for the
|
||||
migration connection). Future schema changes: `alembic revision --autogenerate -m '…'`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
import forge.models # noqa: F401 - register all tables on Base.metadata
|
||||
from forge.config import settings
|
||||
from forge.db.base import Base
|
||||
|
||||
config = context.config
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _sync_url() -> str:
|
||||
# Alembic runs on a synchronous engine, so swap async drivers for an installed sync one:
|
||||
# the stdlib driver for SQLite, and psycopg v3 for Postgres (the `postgres` extra ships
|
||||
# psycopg[binary], not psycopg2). psycopg v3 is addressed as `postgresql+psycopg` and runs
|
||||
# synchronously here. Idempotent if the URL already uses a sync driver.
|
||||
url = settings.database_url
|
||||
return url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg")
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(url=_sync_url(), target_metadata=target_metadata, literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"})
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
engine = create_engine(_sync_url(), poolclass=pool.NullPool)
|
||||
with engine.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""baseline - full current schema
|
||||
|
||||
Squash baseline: stamps the entire current schema via metadata.create_all (skips
|
||||
existing tables, so it's safe on an already-bootstrapped dev DB). Incremental
|
||||
migrations are added on top with `alembic revision --autogenerate`.
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-06-14
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import forge.models # noqa: F401 - register tables
|
||||
from forge.db.base import Base
|
||||
|
||||
revision = "0001_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
Base.metadata.create_all(bind=op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
Base.metadata.drop_all(bind=op.get_bind())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""embed_key + components table (+ unique constraint backfill)
|
||||
|
||||
Adds the schema introduced after the baseline that `create_all` won't apply to an
|
||||
existing database (audit P5):
|
||||
- projects.embed_key (indexed publishable key for the chat widget)
|
||||
- the components table (generative-UI widgets) + its (tenant, project, name)
|
||||
uniqueness constraint
|
||||
|
||||
Idempotent: every step checks the live schema first, so it's safe whether the DB was
|
||||
created fresh by the baseline `create_all` (table/columns already present) or predates
|
||||
these features (they get added). Run on managed Postgres with `alembic upgrade head`.
|
||||
|
||||
Revision ID: 0002_embed_components
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-06-18
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_embed_components"
|
||||
down_revision = "0001_baseline"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_UQ = "uq_component_tenant_project_name"
|
||||
|
||||
|
||||
def _inspector():
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(insp, name: str) -> bool:
|
||||
return name in insp.get_table_names()
|
||||
|
||||
|
||||
def _has_column(insp, table: str, col: str) -> bool:
|
||||
return _has_table(insp, table) and any(c["name"] == col for c in insp.get_columns(table))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = _inspector()
|
||||
|
||||
# 1) projects.embed_key (+ index)
|
||||
if _has_table(insp, "projects") and not _has_column(insp, "projects", "embed_key"):
|
||||
op.add_column("projects", sa.Column("embed_key", sa.String(64), nullable=True))
|
||||
existing_idx = {i["name"] for i in insp.get_indexes("projects")}
|
||||
if "ix_projects_embed_key" not in existing_idx:
|
||||
op.create_index("ix_projects_embed_key", "projects", ["embed_key"])
|
||||
|
||||
# 2) components table
|
||||
if not _has_table(insp, "components"):
|
||||
op.create_table(
|
||||
"components",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("title", sa.String(200), nullable=True),
|
||||
sa.Column("description", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("props_schema", sa.JSON(), nullable=True),
|
||||
sa.Column("html", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("css", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("actions", sa.JSON(), nullable=True),
|
||||
sa.Column("sample_props", sa.JSON(), nullable=True),
|
||||
sa.Column("kind", sa.String(20), server_default="html", nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default=sa.true(), nullable=False),
|
||||
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "project_id", "name", name=_UQ),
|
||||
)
|
||||
op.create_index("ix_components_tenant_id", "components", ["tenant_id"])
|
||||
op.create_index("ix_components_project_id", "components", ["project_id"])
|
||||
else:
|
||||
# Table predates the uniqueness constraint - add it (create_all never alters).
|
||||
existing_uqs = {u["name"] for u in insp.get_unique_constraints("components")}
|
||||
if _UQ not in existing_uqs:
|
||||
if op.get_bind().dialect.name == "sqlite":
|
||||
# SQLite cannot ALTER TABLE ADD CONSTRAINT. Batch mode copies the table,
|
||||
# applies the constraint, and renames it back while preserving its data.
|
||||
with op.batch_alter_table("components") as batch:
|
||||
batch.create_unique_constraint(_UQ, ["tenant_id", "project_id", "name"])
|
||||
else:
|
||||
op.create_unique_constraint(_UQ, "components", ["tenant_id", "project_id", "name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = _inspector()
|
||||
if _has_table(insp, "components"):
|
||||
op.drop_table("components")
|
||||
if _has_column(insp, "projects", "embed_key"):
|
||||
with op.batch_alter_table("projects") as batch:
|
||||
batch.drop_column("embed_key")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""conversation traces + tool-I/O capture columns
|
||||
|
||||
Adds the schema introduced for the Traces conversation view and per-tool-call I/O capture
|
||||
that `create_all` won't apply to an existing database (audit P5, mirrors 0002):
|
||||
- spans.input / spans.output (captured tool-call request/response JSON)
|
||||
- runs.source (where a run originated)
|
||||
- traces.source / actor / end_user_id / user_message / ai_response (one Trace = one
|
||||
conversation turn) + the actor / end_user_id / source indexes the facet + filter
|
||||
queries rely on
|
||||
|
||||
Idempotent: every step checks the live schema first, so it's safe whether the DB was
|
||||
created fresh by the baseline `create_all` (columns/indexes already present) or predates
|
||||
these features (they get added). Dev SQLite also auto-adds the columns via
|
||||
db.base._ensure_new_columns, but that path never creates the indexes - this migration is
|
||||
the authoritative path for managed Postgres (`alembic upgrade head`).
|
||||
|
||||
Revision ID: 0003_conversation_traces
|
||||
Revises: 0002_embed_components
|
||||
Create Date: 2026-07-09
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0003_conversation_traces"
|
||||
down_revision = "0002_embed_components"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _inspector():
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(insp, name: str) -> bool:
|
||||
return name in insp.get_table_names()
|
||||
|
||||
|
||||
def _has_column(insp, table: str, col: str) -> bool:
|
||||
return _has_table(insp, table) and any(c["name"] == col for c in insp.get_columns(table))
|
||||
|
||||
|
||||
def _add_column(insp, table: str, column: sa.Column) -> None:
|
||||
if _has_table(insp, table) and not _has_column(insp, table, column.name):
|
||||
op.add_column(table, column)
|
||||
|
||||
|
||||
def _create_index(insp, name: str, table: str, cols: list[str]) -> None:
|
||||
if not _has_table(insp, table):
|
||||
return
|
||||
if name not in {i["name"] for i in insp.get_indexes(table)}:
|
||||
op.create_index(name, table, cols)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = _inspector()
|
||||
|
||||
# 1) spans: captured tool-call I/O
|
||||
_add_column(insp, "spans", sa.Column("input", sa.JSON(), nullable=True))
|
||||
_add_column(insp, "spans", sa.Column("output", sa.JSON(), nullable=True))
|
||||
|
||||
# 2) runs.source (server_default backfills existing rows so NOT NULL holds on Postgres)
|
||||
_add_column(insp, "runs", sa.Column("source", sa.String(40), nullable=False, server_default="playground"))
|
||||
|
||||
# 3) traces: conversation-turn columns
|
||||
_add_column(insp, "traces", sa.Column("source", sa.String(40), nullable=False, server_default="playground"))
|
||||
_add_column(insp, "traces", sa.Column("actor", sa.String(300), nullable=False, server_default="System"))
|
||||
_add_column(insp, "traces", sa.Column("end_user_id", sa.String(200), nullable=True))
|
||||
_add_column(insp, "traces", sa.Column("user_message", sa.Text(), nullable=True))
|
||||
_add_column(insp, "traces", sa.Column("ai_response", sa.Text(), nullable=True))
|
||||
|
||||
# 4) indexes the conversation list / facets / filters depend on
|
||||
_create_index(insp, "ix_traces_actor", "traces", ["actor"])
|
||||
_create_index(insp, "ix_traces_end_user_id", "traces", ["end_user_id"])
|
||||
_create_index(insp, "ix_traces_source", "traces", ["source"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = _inspector()
|
||||
for name, table in (
|
||||
("ix_traces_source", "traces"),
|
||||
("ix_traces_end_user_id", "traces"),
|
||||
("ix_traces_actor", "traces"),
|
||||
):
|
||||
if _has_table(insp, table) and name in {i["name"] for i in insp.get_indexes(table)}:
|
||||
op.drop_index(name, table_name=table)
|
||||
for table, col in (
|
||||
("traces", "ai_response"), ("traces", "user_message"), ("traces", "end_user_id"),
|
||||
("traces", "actor"), ("traces", "source"), ("runs", "source"),
|
||||
("spans", "output"), ("spans", "input"),
|
||||
):
|
||||
if _has_column(insp, table, col):
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.drop_column(col)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""entity versions + eval history + platform-hardening tables
|
||||
|
||||
Adds the tables introduced by the feature-bounty work that `create_all` builds in dev but
|
||||
that managed Postgres needs an explicit migration for:
|
||||
- entity_versions (point-in-time snapshots for view/restore across all entity types)
|
||||
- eval_runs (persisted eval executions + regression-gate baseline)
|
||||
- eval_results (per-item eval outcomes)
|
||||
- api_keys (hashed, revocable, per-tenant/role server-to-server keys)
|
||||
- project_members (per-project role grants, additive over the tenant-wide role)
|
||||
- user_security (email-verification flag + optional TOTP MFA, kept off `users`)
|
||||
|
||||
Column types / nullability / index names mirror the ORM's `create_all` output so a dev
|
||||
SQLite database and a migrated Postgres database converge on the same schema. NOT NULL
|
||||
columns that carry an ORM-side default also get a `server_default` (mirrors 0003's
|
||||
`runs.source`) so the tables are insertable via raw SQL and future backfills are
|
||||
unambiguous. created_at/updated_at are left nullable to match 0002/0003.
|
||||
|
||||
Idempotent: each table is created only when absent, so this is safe whether the DB was
|
||||
created fresh by the baseline `create_all` (tables already present) or predates these
|
||||
features. Run on managed Postgres with `alembic upgrade head`. Tenant-isolation RLS
|
||||
policies for these tables live in infra/postgres_rls.sql.
|
||||
|
||||
Revision ID: 0004_versions_evals_platform
|
||||
Revises: 0003_conversation_traces
|
||||
Create Date: 2026-07-14
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0004_versions_evals_platform"
|
||||
down_revision = "0003_conversation_traces"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _inspector():
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(insp, name: str) -> bool:
|
||||
return name in insp.get_table_names()
|
||||
|
||||
|
||||
def _pk_timestamp_cols() -> list[sa.Column]:
|
||||
# Mirrors db.base.PkTimestamp: string-UUID PK + created/updated timestamps (nullable to
|
||||
# match the 0002/0003 precedent; the ORM populates them on every insert).
|
||||
return [
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = _inspector()
|
||||
|
||||
# --- entity_versions -----------------------------------------------------------------
|
||||
if not _has_table(insp, "entity_versions"):
|
||||
op.create_table(
|
||||
"entity_versions",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=True),
|
||||
sa.Column("entity_type", sa.String(40), nullable=False),
|
||||
sa.Column("entity_id", sa.String(36), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("label", sa.String(300), nullable=True),
|
||||
sa.Column("snapshot", sa.JSON(), server_default=sa.text("'{}'"), nullable=False),
|
||||
sa.Column("author_id", sa.String(36), nullable=True),
|
||||
sa.Column("author_email", sa.String(320), nullable=True),
|
||||
sa.UniqueConstraint("entity_type", "entity_id", "version_no", name="uq_entity_version"),
|
||||
)
|
||||
op.create_index("ix_entity_versions_tenant_id", "entity_versions", ["tenant_id"])
|
||||
op.create_index("ix_entity_versions_project_id", "entity_versions", ["project_id"])
|
||||
op.create_index("ix_entity_versions_entity_type", "entity_versions", ["entity_type"])
|
||||
op.create_index("ix_entity_versions_entity_id", "entity_versions", ["entity_id"])
|
||||
|
||||
# --- eval_runs -----------------------------------------------------------------------
|
||||
if not _has_table(insp, "eval_runs"):
|
||||
op.create_table(
|
||||
"eval_runs",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("dataset_id", sa.String(36), nullable=False),
|
||||
sa.Column("workflow_id", sa.String(36), nullable=True),
|
||||
sa.Column("score_mode", sa.String(20), server_default="contains", nullable=False),
|
||||
sa.Column("status", sa.String(20), server_default="done", nullable=False),
|
||||
sa.Column("total", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("passed", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("pass_rate", sa.Float(), server_default="0", nullable=False),
|
||||
sa.Column("prev_pass_rate", sa.Float(), nullable=True),
|
||||
sa.Column("regressed", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("total_tokens", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("total_cost_usd", sa.Float(), server_default="0", nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), server_default=sa.text("'{}'"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_eval_runs_tenant_id", "eval_runs", ["tenant_id"])
|
||||
op.create_index("ix_eval_runs_project_id", "eval_runs", ["project_id"])
|
||||
op.create_index("ix_eval_runs_dataset_id", "eval_runs", ["dataset_id"])
|
||||
|
||||
# --- eval_results --------------------------------------------------------------------
|
||||
if not _has_table(insp, "eval_results"):
|
||||
op.create_table(
|
||||
"eval_results",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("eval_run_id", sa.String(36), nullable=False),
|
||||
sa.Column("item_index", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("input", sa.Text(), nullable=True),
|
||||
sa.Column("expected", sa.Text(), nullable=True),
|
||||
sa.Column("answer", sa.Text(), nullable=True),
|
||||
sa.Column("passed", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="scored", nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("checks", sa.JSON(), server_default=sa.text("'[]'"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_eval_results_tenant_id", "eval_results", ["tenant_id"])
|
||||
op.create_index("ix_eval_results_eval_run_id", "eval_results", ["eval_run_id"])
|
||||
|
||||
# --- api_keys ------------------------------------------------------------------------
|
||||
if not _has_table(insp, "api_keys"):
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("prefix", sa.String(16), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("role", sa.String(30), server_default="editor", nullable=False),
|
||||
sa.Column("status", sa.String(20), server_default="active", nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by", sa.String(36), nullable=True),
|
||||
)
|
||||
op.create_index("ix_api_keys_tenant_id", "api_keys", ["tenant_id"])
|
||||
op.create_index("ix_api_keys_prefix", "api_keys", ["prefix"])
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
|
||||
# --- project_members -----------------------------------------------------------------
|
||||
if not _has_table(insp, "project_members"):
|
||||
op.create_table(
|
||||
"project_members",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("role", sa.String(30), server_default="viewer", nullable=False),
|
||||
sa.UniqueConstraint("project_id", "user_id", name="uq_project_member"),
|
||||
)
|
||||
op.create_index("ix_project_members_tenant_id", "project_members", ["tenant_id"])
|
||||
op.create_index("ix_project_members_project_id", "project_members", ["project_id"])
|
||||
op.create_index("ix_project_members_user_id", "project_members", ["user_id"])
|
||||
|
||||
# --- user_security -------------------------------------------------------------------
|
||||
if not _has_table(insp, "user_security"):
|
||||
op.create_table(
|
||||
"user_security",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("email_verified", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("email_verified_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("totp_secret", sa.String(64), nullable=True),
|
||||
sa.Column("totp_enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", name="uq_user_security_user"),
|
||||
)
|
||||
op.create_index("ix_user_security_tenant_id", "user_security", ["tenant_id"])
|
||||
op.create_index("ix_user_security_user_id", "user_security", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = _inspector()
|
||||
for table in (
|
||||
"user_security", "project_members", "api_keys",
|
||||
"eval_results", "eval_runs", "entity_versions",
|
||||
):
|
||||
if _has_table(insp, table):
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""scope user email uniqueness to a workspace
|
||||
|
||||
The original schema made ``users.email`` globally unique. A user row belongs to one
|
||||
tenant, so that prevented the same person from joining more than one workspace. Replace
|
||||
the unique email index with a normal lookup index plus a composite tenant/email
|
||||
constraint. Existing data is safe because global uniqueness was stricter.
|
||||
|
||||
Revision ID: 0005_tenant_scoped_user_email
|
||||
Revises: 0004_versions_evals_platform
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0005_tenant_scoped_user_email"
|
||||
down_revision = "0004_versions_evals_platform"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _indexes() -> dict[str, dict]:
|
||||
return {idx["name"]: idx for idx in sa.inspect(op.get_bind()).get_indexes("users")}
|
||||
|
||||
|
||||
def _unique_constraints() -> dict[str, dict]:
|
||||
return {
|
||||
constraint["name"]: constraint
|
||||
for constraint in sa.inspect(op.get_bind()).get_unique_constraints("users")
|
||||
if constraint.get("name")
|
||||
}
|
||||
|
||||
|
||||
def _create_tenant_email_constraint() -> None:
|
||||
if "uq_users_tenant_email" in _unique_constraints():
|
||||
return
|
||||
if op.get_bind().dialect.name == "sqlite":
|
||||
with op.batch_alter_table("users", recreate="always") as batch:
|
||||
batch.create_unique_constraint("uq_users_tenant_email", ["tenant_id", "email"])
|
||||
else:
|
||||
op.create_unique_constraint("uq_users_tenant_email", "users", ["tenant_id", "email"])
|
||||
|
||||
|
||||
def _drop_tenant_email_constraint() -> None:
|
||||
if "uq_users_tenant_email" not in _unique_constraints():
|
||||
return
|
||||
if op.get_bind().dialect.name == "sqlite":
|
||||
with op.batch_alter_table("users", recreate="always") as batch:
|
||||
batch.drop_constraint("uq_users_tenant_email", type_="unique")
|
||||
else:
|
||||
op.drop_constraint("uq_users_tenant_email", "users", type_="unique")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
indexes = _indexes()
|
||||
email_index = indexes.get("ix_users_email")
|
||||
if email_index and email_index.get("unique"):
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
_create_tenant_email_constraint()
|
||||
if "ix_users_email" not in _indexes():
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
indexes = _indexes()
|
||||
if "ix_users_email" in indexes:
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
_drop_tenant_email_constraint()
|
||||
if "ix_users_email" not in _indexes():
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""drop the per-entity version counter from tools and agents
|
||||
|
||||
The ``tools.version`` / ``agents.version`` integer counter only bumped on save and was
|
||||
purely a display badge ("Save v19") - nothing in the runtime read it. The per-entity change
|
||||
history (``entity_versions``) is now the single source of truth for "what changed when", so
|
||||
the redundant counter is removed. Component.version (a client render-cache key), Secret.version
|
||||
and Workflow.active_version are intentionally left in place.
|
||||
|
||||
Revision ID: 0006_drop_tool_agent_version
|
||||
Revises: 0005_tenant_scoped_user_email
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0006_drop_tool_agent_version"
|
||||
down_revision = "0005_tenant_scoped_user_email"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ("tools", "agents")
|
||||
|
||||
|
||||
def _has_version(table: str) -> bool:
|
||||
return any(c["name"] == "version" for c in sa.inspect(op.get_bind()).get_columns(table))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
if not _has_version(table):
|
||||
continue
|
||||
if op.get_bind().dialect.name == "sqlite":
|
||||
with op.batch_alter_table(table, recreate="always") as batch:
|
||||
batch.drop_column("version")
|
||||
else:
|
||||
op.drop_column(table, "version")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _TABLES:
|
||||
if _has_version(table):
|
||||
continue
|
||||
# Re-add as a non-null counter defaulting to 1 (the original schema default).
|
||||
op.add_column(table, sa.Column("version", sa.Integer(), nullable=False, server_default="1"))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""tool sets + membership
|
||||
|
||||
Adds first-class Tool Sets (describable groups of tools) and their many-to-many membership
|
||||
join, introduced by the tool-sets / MCP-toolsets work. `create_all` builds these in dev;
|
||||
managed Postgres needs this migration.
|
||||
|
||||
- tool_sets (name, slug, description, icon, is_default; unique slug per project)
|
||||
- tool_set_members (tool_set_id <-> tool_id, unique per pair)
|
||||
|
||||
Column types / nullability mirror the ORM's `create_all` output so a dev SQLite database and
|
||||
a migrated Postgres database converge. NOT NULL columns that carry an ORM-side default also
|
||||
get a `server_default` (mirrors 0004) so the tables are insertable via raw SQL.
|
||||
|
||||
Idempotent: each table is created only when absent, so this is safe whether the DB was
|
||||
created fresh by `create_all` (tables already present) or predates the feature. Tenant-
|
||||
isolation RLS policies for these tables live in infra/postgres_rls.sql.
|
||||
|
||||
Revision ID: 0007_tool_sets
|
||||
Revises: 0006_drop_tool_agent_version
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0007_tool_sets"
|
||||
down_revision = "0006_drop_tool_agent_version"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _inspector():
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(insp, name: str) -> bool:
|
||||
return name in insp.get_table_names()
|
||||
|
||||
|
||||
def _pk_timestamp_cols() -> list[sa.Column]:
|
||||
# Mirrors db.base.PkTimestamp: string-UUID PK + created/updated timestamps (nullable to
|
||||
# match the 0002/0003/0004 precedent; the ORM populates them on every insert).
|
||||
return [
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = _inspector()
|
||||
|
||||
# --- tool_sets -----------------------------------------------------------------------
|
||||
if not _has_table(insp, "tool_sets"):
|
||||
op.create_table(
|
||||
"tool_sets",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("slug", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("icon", sa.String(60), nullable=True),
|
||||
sa.Column("is_default", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "project_id", "slug", name="uq_tool_set_slug"),
|
||||
)
|
||||
op.create_index("ix_tool_sets_tenant_id", "tool_sets", ["tenant_id"])
|
||||
op.create_index("ix_tool_sets_project_id", "tool_sets", ["project_id"])
|
||||
op.create_index("ix_tool_sets_slug", "tool_sets", ["slug"])
|
||||
|
||||
# --- tool_set_members ----------------------------------------------------------------
|
||||
if not _has_table(insp, "tool_set_members"):
|
||||
op.create_table(
|
||||
"tool_set_members",
|
||||
*_pk_timestamp_cols(),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("tool_set_id", sa.String(36), nullable=False),
|
||||
sa.Column("tool_id", sa.String(36), nullable=False),
|
||||
sa.UniqueConstraint("tool_set_id", "tool_id", name="uq_tool_set_member"),
|
||||
)
|
||||
op.create_index("ix_tool_set_members_tenant_id", "tool_set_members", ["tenant_id"])
|
||||
op.create_index("ix_tool_set_members_project_id", "tool_set_members", ["project_id"])
|
||||
op.create_index("ix_tool_set_members_tool_set_id", "tool_set_members", ["tool_set_id"])
|
||||
op.create_index("ix_tool_set_members_tool_id", "tool_set_members", ["tool_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = _inspector()
|
||||
for table in ("tool_set_members", "tool_sets"):
|
||||
if _has_table(insp, table):
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""api_keys: user_id + project_id (personal access tokens for MCP)
|
||||
|
||||
Adds two nullable columns to `api_keys` so a key can be a per-user Personal Access Token (PAT)
|
||||
scoped to a single project - used to authenticate an individual over a project's MCP server as an
|
||||
end_user. Existing (tenant, role) server-to-server keys leave both NULL and are unaffected.
|
||||
|
||||
Idempotent: each column is added only when absent, so this is safe whether the table was created
|
||||
fresh by `create_all` (columns already present via the ORM) or predates the feature.
|
||||
|
||||
Revision ID: 0008_apikey_user_scope
|
||||
Revises: 0007_tool_sets
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0008_apikey_user_scope"
|
||||
down_revision = "0007_tool_sets"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _cols(insp, table: str) -> set[str]:
|
||||
return {c["name"] for c in insp.get_columns(table)} if table in insp.get_table_names() else set()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
existing = _cols(insp, "api_keys")
|
||||
if "api_keys" not in insp.get_table_names():
|
||||
return # fresh DBs build the column from the ORM; nothing to alter
|
||||
if "user_id" not in existing:
|
||||
op.add_column("api_keys", sa.Column("user_id", sa.String(36), nullable=True))
|
||||
if "project_id" not in existing:
|
||||
op.add_column("api_keys", sa.Column("project_id", sa.String(36), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
existing = _cols(insp, "api_keys")
|
||||
if "project_id" in existing:
|
||||
op.drop_column("api_keys", "project_id")
|
||||
if "user_id" in existing:
|
||||
op.drop_column("api_keys", "user_id")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""oauth_clients (MCP OAuth 2.1 dynamic client registration)
|
||||
|
||||
Adds the `oauth_clients` table: dynamically-registered OAuth 2.1 public clients (RFC 7591) for the
|
||||
MCP authorization server (a client_id + an exact-match redirect_uri allow-list). Global registry
|
||||
(no tenant_id) - the user identity is bound later at the authorize step. Only used when
|
||||
`settings.mcp_oauth_enabled` is on. `create_all` builds this in dev; managed Postgres needs this.
|
||||
|
||||
Idempotent: created only when absent.
|
||||
|
||||
Revision ID: 0009_oauth_clients
|
||||
Revises: 0008_apikey_user_scope
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0009_oauth_clients"
|
||||
down_revision = "0008_apikey_user_scope"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
if "oauth_clients" not in insp.get_table_names():
|
||||
op.create_table(
|
||||
"oauth_clients",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("client_id", sa.String(64), nullable=False),
|
||||
sa.Column("client_name", sa.String(200), nullable=True),
|
||||
sa.Column("redirect_uris", sa.JSON(), server_default=sa.text("'[]'"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_oauth_clients_client_id", "oauth_clients", ["client_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
if "oauth_clients" in insp.get_table_names():
|
||||
op.drop_table("oauth_clients")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""tool_sets.exposed (GitHub-style MCP exposure)
|
||||
|
||||
Adds `exposed` to `tool_sets`: the MCP surface is exactly the enabled tools of exposed tool sets
|
||||
(no loose per-tool exposure). Defaults to true so existing sets keep publishing. `create_all`
|
||||
builds it on fresh dev DBs; this migration covers managed Postgres and pre-existing tables.
|
||||
|
||||
Idempotent: added only when absent.
|
||||
|
||||
Revision ID: 0010_toolset_exposed
|
||||
Revises: 0009_oauth_clients
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0010_toolset_exposed"
|
||||
down_revision = "0009_oauth_clients"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
if "tool_sets" not in insp.get_table_names():
|
||||
return
|
||||
cols = {c["name"] for c in insp.get_columns("tool_sets")}
|
||||
if "exposed" not in cols:
|
||||
op.add_column("tool_sets", sa.Column("exposed", sa.Boolean(), server_default=sa.true(), nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
if "tool_sets" in insp.get_table_names() and "exposed" in {c["name"] for c in insp.get_columns("tool_sets")}:
|
||||
op.drop_column("tool_sets", "exposed")
|
||||
Reference in New Issue
Block a user