Files
forge/apps/api/migrations/versions/0003_conversation_traces.py
nihalashetty ae67bff5a3 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.
2026-07-28 01:49:19 +05:30

94 lines
3.8 KiB
Python

"""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)