Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions backend/alembic/versions/009_add_function_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Add function_versions table and migrate code from functions.

Creates the function_versions table with composite PK (function_id, version).
Migrates existing function code into version 1 rows, adds active_version
column to functions, then drops the code column.

Revision ID: 009
Revises: 008
Create Date: 2026-02-21
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# 1. Create the function_versions table
op.create_table(
"function_versions",
sa.Column("function_id", sa.String(), sa.ForeignKey("functions.id"), primary_key=True),
sa.Column("version", sa.Integer(), primary_key=True),
sa.Column("code", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)

# 2. Add active_version column to functions (default 1)
op.add_column(
"functions",
sa.Column("active_version", sa.Integer(), nullable=False, server_default="1"),
)

# 3. Migrate existing code into function_versions as version 1
op.execute(
"INSERT INTO function_versions (function_id, version, code, created_at, updated_at) "
"SELECT id, 1, code, created_at, updated_at FROM functions"
)

# 4. Drop the code column from functions
op.drop_column("functions", "code")


def downgrade() -> None:
# 1. Re-add code column to functions
op.add_column(
"functions",
sa.Column("code", sa.Text(), nullable=False, server_default=""),
)

# 2. Restore code from the active version
op.execute(
"UPDATE functions SET code = ("
" SELECT fv.code FROM function_versions fv "
" WHERE fv.function_id = functions.id AND fv.version = functions.active_version"
")"
)

# 3. Drop active_version column
op.drop_column("functions", "active_version")

# 4. Drop function_versions table
op.drop_table("function_versions")
27 changes: 26 additions & 1 deletion backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class Function(Base):
)
name: Mapped[str] = mapped_column(index=True)
description: Mapped[str] = mapped_column(default="")
code: Mapped[str] = mapped_column(Text)
active_version: Mapped[int] = mapped_column(default=1)
runtime: Mapped[str] = mapped_column(default="python")
status: Mapped[str] = mapped_column(default="active")
network_enabled: Mapped[bool] = mapped_column(default=False)
Expand All @@ -107,6 +107,9 @@ class Function(Base):
routes: Mapped[list["Route"]] = relationship(
back_populates="function", cascade="all, delete-orphan"
)
versions: Mapped[list["FunctionVersion"]] = relationship(
back_populates="function", cascade="all, delete-orphan"
)


class Invocation(Base):
Expand Down Expand Up @@ -175,6 +178,28 @@ class EnvVar(Base):
project: Mapped["Project"] = relationship(back_populates="env_vars")


class FunctionVersion(Base):
"""
A versioned snapshot of a function's code.

Each time a function's code is updated, a new version row is created.
The composite primary key (function_id, version) uniquely identifies
each version of a function's code.
"""

__tablename__ = "function_versions"

function_id: Mapped[str] = mapped_column(
ForeignKey("functions.id"), primary_key=True
)
version: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)

function: Mapped["Function"] = relationship(back_populates="versions")


class Route(Base):
"""
An HTTP route that maps a method + path pattern to a function.
Expand Down
27 changes: 23 additions & 4 deletions backend/app/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

from app.auth import get_current_user
from app.database import get_db
from app.models import Function, Invocation
from app.models import Function, FunctionVersion, Invocation
from app.services.ai_agent import chat_with_tools
from app.services.invoke_service import InvokeService

Expand Down Expand Up @@ -123,11 +123,19 @@ async def _tool_create_function(db: AsyncSession, user_id: str, args: dict) -> d
fn = Function(
name=args["name"],
description=args.get("description", ""),
code=args["code"],
runtime="python",
user_id=user_id,
active_version=1,
)
db.add(fn)
await db.flush()

version = FunctionVersion(
function_id=fn.id,
version=1,
code=args["code"],
)
db.add(version)
await db.commit()
await db.refresh(fn)
return {
Expand Down Expand Up @@ -176,8 +184,12 @@ async def _tool_invoke_function(
if fn.status != "active":
return {"error": f"Function is not active (status: {fn.status})"}

version = await db.get(FunctionVersion, (fn.id, fn.active_version))
if not version:
return {"error": "Active version not found"}

input_data = args.get("input", {})
result = await invoke_service.invoke(code=fn.code, input_data=input_data)
result = await invoke_service.invoke(code=version.code, input_data=input_data)

# Save invocation log
invocation = Invocation(
Expand Down Expand Up @@ -240,7 +252,14 @@ async def _tool_update_function(db: AsyncSession, user_id: str, args: dict) -> d
if "description" in args and args["description"]:
fn.description = args["description"]
if "code" in args and args["code"]:
fn.code = args["code"]
new_version_num = fn.active_version + 1
new_version = FunctionVersion(
function_id=fn.id,
version=new_version_num,
code=args["code"],
)
db.add(new_version)
fn.active_version = new_version_num

await db.commit()
await db.refresh(fn)
Expand Down
119 changes: 107 additions & 12 deletions backend/app/routers/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,42 @@

from app.auth import get_current_user
from app.database import get_db
from app.models import Function
from app.schemas import FunctionCreate, FunctionResponse, FunctionUpdate
from app.models import Function, FunctionVersion
from app.schemas import (
FunctionCreate,
FunctionResponse,
FunctionUpdate,
FunctionVersionResponse,
)

# Create a router with a URL prefix. All routes in this file will start
# with "/api/functions". The "tags" group these endpoints together in the
# auto-generated docs at /docs.
router = APIRouter(prefix="/api/functions", tags=["functions"])


async def _resolve_code(fn: Function, db: AsyncSession) -> str:
"""Fetch the code for a function's active version."""
version = await db.get(FunctionVersion, (fn.id, fn.active_version))
return version.code if version else ""


def _fn_response(fn: Function, code: str) -> dict:
"""Build a FunctionResponse-compatible dict with versioned code."""
return {
"id": fn.id,
"name": fn.name,
"description": fn.description,
"code": code,
"active_version": fn.active_version,
"runtime": fn.runtime,
"status": fn.status,
"network_enabled": fn.network_enabled,
"created_at": fn.created_at,
"updated_at": fn.updated_at,
}


@router.post("", response_model=FunctionResponse)
async def create_function(
data: FunctionCreate,
Expand All @@ -42,15 +69,23 @@ async def create_function(
fn = Function(
name=data.name,
description=data.description,
code=data.code,
runtime=data.runtime,
user_id=user_id,
project_id=data.project_id,
active_version=1,
)
db.add(fn) # Stage the new row for insertion
await db.commit() # Write it to the database
await db.refresh(fn) # Reload from DB to get generated fields (id, timestamps)
return fn
db.add(fn)
await db.flush() # Generate fn.id before creating the version

version = FunctionVersion(
function_id=fn.id,
version=1,
code=data.code,
)
db.add(version)
await db.commit()
await db.refresh(fn)
return _fn_response(fn, data.code)


@router.get("", response_model=list[FunctionResponse])
Expand All @@ -64,7 +99,10 @@ async def list_functions(
.where(Function.user_id == user_id)
.order_by(Function.created_at.desc())
)
return result.scalars().all()
functions = result.scalars().all()
return [
_fn_response(fn, await _resolve_code(fn, db)) for fn in functions
]


@router.get("/{function_id}", response_model=FunctionResponse)
Expand All @@ -77,7 +115,8 @@ async def get_function(
fn = await db.get(Function, function_id)
if not fn or fn.user_id != user_id:
raise HTTPException(status_code=404, detail="Function not found")
return fn
code = await _resolve_code(fn, db)
return _fn_response(fn, code)


@router.put("/{function_id}", response_model=FunctionResponse)
Expand All @@ -98,13 +137,69 @@ async def update_function(
if not fn or fn.user_id != user_id:
raise HTTPException(status_code=404, detail="Function not found")

# Loop through each provided field and update the model attribute
for field, value in data.model_dump(exclude_unset=True).items():
updates = data.model_dump(exclude_unset=True)
new_code = updates.pop("code", None)

# Update non-code fields on the function
for field, value in updates.items():
setattr(fn, field, value)

# If code changed, create a new version
if new_code is not None:
new_version_num = fn.active_version + 1
version = FunctionVersion(
function_id=fn.id,
version=new_version_num,
code=new_code,
)
db.add(version)
fn.active_version = new_version_num

await db.commit()
await db.refresh(fn)
code = await _resolve_code(fn, db)
return _fn_response(fn, code)


@router.get("/{function_id}/versions", response_model=list[FunctionVersionResponse])
async def list_versions(
function_id: str,
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all versions for a function, newest first."""
fn = await db.get(Function, function_id)
if not fn or fn.user_id != user_id:
raise HTTPException(status_code=404, detail="Function not found")

result = await db.execute(
select(FunctionVersion)
.where(FunctionVersion.function_id == function_id)
.order_by(FunctionVersion.version.desc())
)
return result.scalars().all()


@router.put("/{function_id}/versions/{version}", response_model=FunctionResponse)
async def set_active_version(
function_id: str,
version: int,
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set a specific version as the active version."""
fn = await db.get(Function, function_id)
if not fn or fn.user_id != user_id:
raise HTTPException(status_code=404, detail="Function not found")

fv = await db.get(FunctionVersion, (function_id, version))
if not fv:
raise HTTPException(status_code=404, detail="Version not found")

fn.active_version = version
await db.commit()
await db.refresh(fn)
return fn
return _fn_response(fn, fv.code)


@router.delete("/{function_id}")
Expand Down
16 changes: 12 additions & 4 deletions backend/app/routers/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.models import Function, Invocation, Project, Route
from app.models import Function, FunctionVersion, Invocation, Project, Route
from app.services.context import resolve_context

router = APIRouter(prefix="/api/gateway", tags=["gateway"])
Expand Down Expand Up @@ -170,13 +170,21 @@ async def _handle_gateway(
"body": body,
}

# Step 6: Resolve execution context (env vars, custom image, DATABASE_URL)
# Step 6: Resolve active version's code
version = await db.get(FunctionVersion, (fn.id, fn.active_version))
if not version:
return JSONResponse(
status_code=500,
content={"error": "Active version not found"},
)

# Step 7: Resolve execution context (env vars, custom image, DATABASE_URL)
ctx = await resolve_context(project.id, db)

# Step 7: Run the function via InvokeService
# Step 8: Run the function via InvokeService
invoke_service = request.app.state.invoke_service
result = await invoke_service.invoke(
code=fn.code,
code=version.code,
input_data=event,
env_vars=ctx.env_vars,
function_name=fn.name,
Expand Down
Loading