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:
nihalashetty
2026-07-28 01:49:19 +05:30
commit ae67bff5a3
350 changed files with 58244 additions and 0 deletions
@@ -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")