refactor: update migration paths and improve database operation handling

This commit is contained in:
Abhimanyu Saharan
2026-02-09 00:51:26 +05:30
parent 8c4bcca603
commit f6bcd1ca5f
43 changed files with 1175 additions and 1445 deletions

View File

@@ -3,7 +3,7 @@
## Project Structure & Module Organization
- `backend/`: FastAPI service.
- App code: `backend/app/` (routes `backend/app/api/`, models `backend/app/models/`, schemas `backend/app/schemas/`, workers `backend/app/workers/`).
- DB migrations: `backend/alembic/` (generated versions in `backend/alembic/versions/`).
- DB migrations: `backend/migrations/` (generated versions in `backend/migrations/versions/`).
- Tests: `backend/tests/`.
- `frontend/`: Next.js app.
- Routes: `frontend/src/app/`; shared UI: `frontend/src/components/`; utilities: `frontend/src/lib/`.

View File

@@ -101,7 +101,7 @@ frontend-test: frontend-tooling ## Frontend tests (vitest)
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run test
.PHONY: backend-migrate
backend-migrate: ## Apply backend DB migrations (alembic upgrade head)
backend-migrate: ## Apply backend DB migrations (uses backend/migrations)
cd $(BACKEND_DIR) && uv run alembic upgrade head
.PHONY: build

View File

@@ -4,7 +4,7 @@ extend-ignore = E203, W503, E501
exclude =
.venv,
backend/.venv,
alembic,
backend/alembic,
migrations,
backend/migrations,
**/__pycache__,
**/*.pyc

View File

@@ -34,7 +34,7 @@ COPY --from=deps /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:${PATH}"
# Copy app source
COPY backend/alembic ./alembic
COPY backend/migrations ./migrations
COPY backend/alembic.ini ./alembic.ini
COPY backend/app ./app

View File

@@ -77,7 +77,7 @@ Clerk is used for user authentication (optional for local/self-host in many setu
## Database migrations (Alembic)
Migrations live in `backend/alembic/versions/*`.
Migrations live in `backend/migrations/versions/*`.
Common commands:

View File

@@ -1,5 +1,5 @@
[alembic]
script_location = alembic
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname

View File

@@ -1,89 +0,0 @@
"""backfill_invite_access
Revision ID: 050c16fde00e
Revises: 2c7b1c4d9e10
Create Date: 2026-02-08 20:07:14.621575
"""
from __future__ import annotations
from datetime import datetime
import uuid
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '050c16fde00e'
down_revision = '2c7b1c4d9e10'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
now = datetime.utcnow()
rows = bind.execute(
sa.text(
"""
SELECT
m.id AS member_id,
iba.board_id AS board_id,
iba.can_read AS can_read,
iba.can_write AS can_write
FROM organization_invites i
JOIN organization_invite_board_access iba
ON iba.organization_invite_id = i.id
JOIN organization_members m
ON m.user_id = i.accepted_by_user_id
AND m.organization_id = i.organization_id
WHERE i.accepted_at IS NOT NULL
"""
)
).fetchall()
for row in rows:
can_write = bool(row.can_write)
can_read = bool(row.can_read or row.can_write)
bind.execute(
sa.text(
"""
INSERT INTO organization_board_access (
id,
organization_member_id,
board_id,
can_read,
can_write,
created_at,
updated_at
)
VALUES (
:id,
:member_id,
:board_id,
:can_read,
:can_write,
:now,
:now
)
ON CONFLICT (organization_member_id, board_id) DO UPDATE
SET
can_read = organization_board_access.can_read OR EXCLUDED.can_read,
can_write = organization_board_access.can_write OR EXCLUDED.can_write,
updated_at = EXCLUDED.updated_at
"""
),
{
"id": uuid.uuid4(),
"member_id": row.member_id,
"board_id": row.board_id,
"can_read": can_read,
"can_write": can_write,
"now": now,
},
)
def downgrade() -> None:
pass

View File

@@ -1,54 +0,0 @@
"""board groups
Revision ID: 12772fdcdfe9
Revises: 9f0c4fb2a7b8
Create Date: 2026-02-07 17:13:50.597099
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision = "12772fdcdfe9"
down_revision = "9f0c4fb2a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"board_groups",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False)
op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True))
op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False)
op.create_foreign_key(
"fk_boards_board_group_id_board_groups",
"boards",
"board_groups",
["board_group_id"],
["id"],
)
def downgrade() -> None:
op.drop_constraint(
"fk_boards_board_group_id_board_groups", "boards", type_="foreignkey"
)
op.drop_index("ix_boards_board_group_id", table_name="boards")
op.drop_column("boards", "board_group_id")
op.drop_index("ix_board_groups_slug", table_name="board_groups")
op.drop_table("board_groups")

View File

@@ -1,388 +0,0 @@
"""init (squashed)
Revision ID: 1d844b04ee06
Revises:
Create Date: 2026-02-06
This is a squashed init migration representing the current schema at revision
`1d844b04ee06`.
Note: older Alembic revision files were consolidated into this single revision.
Databases already stamped/applied at `1d844b04ee06` will remain compatible.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision = "1d844b04ee06"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"gateways",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("main_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("workspace_root", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column(
"skyll_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"boards",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("gateway_id", sa.Uuid(), nullable=True),
sa.Column(
"board_type",
sa.String(),
server_default="goal",
nullable=False,
),
sa.Column("objective", sa.Text(), nullable=True),
sa.Column("success_metrics", sa.JSON(), nullable=True),
sa.Column("target_date", sa.DateTime(), nullable=True),
sa.Column(
"goal_confirmed",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("goal_source", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["gateway_id"], ["gateways.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_boards_slug", "boards", ["slug"], unique=False)
op.create_index("ix_boards_gateway_id", "boards", ["gateway_id"], unique=False)
op.create_index("ix_boards_board_type", "boards", ["board_type"], unique=False)
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("clerk_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("preferred_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("pronouns", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("timezone", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("context", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("is_super_admin", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_users_clerk_user_id", "users", ["clerk_user_id"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=False)
op.create_table(
"agents",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("openclaw_session_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("agent_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("heartbeat_config", sa.JSON(), nullable=True),
sa.Column("identity_profile", sa.JSON(), nullable=True),
sa.Column("identity_template", sa.Text(), nullable=True),
sa.Column("soul_template", sa.Text(), nullable=True),
sa.Column("provision_requested_at", sa.DateTime(), nullable=True),
sa.Column(
"provision_confirm_token_hash",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
sa.Column("provision_action", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("delete_requested_at", sa.DateTime(), nullable=True),
sa.Column(
"delete_confirm_token_hash",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column(
"is_board_lead",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_agents_board_id", "agents", ["board_id"], unique=False)
op.create_index("ix_agents_name", "agents", ["name"], unique=False)
op.create_index("ix_agents_status", "agents", ["status"], unique=False)
op.create_index(
"ix_agents_openclaw_session_id", "agents", ["openclaw_session_id"], unique=False
)
op.create_index("ix_agents_agent_token_hash", "agents", ["agent_token_hash"], unique=False)
op.create_index(
"ix_agents_provision_confirm_token_hash",
"agents",
["provision_confirm_token_hash"],
unique=False,
)
op.create_index("ix_agents_provision_action", "agents", ["provision_action"], unique=False)
op.create_index(
"ix_agents_delete_confirm_token_hash",
"agents",
["delete_confirm_token_hash"],
unique=False,
)
op.create_index("ix_agents_is_board_lead", "agents", ["is_board_lead"], unique=False)
op.create_table(
"tasks",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=True),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("due_at", sa.DateTime(), nullable=True),
sa.Column("in_progress_at", sa.DateTime(), nullable=True),
sa.Column("created_by_user_id", sa.Uuid(), nullable=True),
sa.Column("assigned_agent_id", sa.Uuid(), nullable=True),
sa.Column(
"auto_created",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("auto_reason", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["assigned_agent_id"], ["agents.id"]),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_tasks_board_id", "tasks", ["board_id"], unique=False)
op.create_index("ix_tasks_status", "tasks", ["status"], unique=False)
op.create_index("ix_tasks_priority", "tasks", ["priority"], unique=False)
op.create_index("ix_tasks_due_at", "tasks", ["due_at"], unique=False)
op.create_index("ix_tasks_assigned_agent_id", "tasks", ["assigned_agent_id"], unique=False)
op.create_index("ix_tasks_created_by_user_id", "tasks", ["created_by_user_id"], unique=False)
op.create_table(
"activity_events",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("message", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("agent_id", sa.Uuid(), nullable=True),
sa.Column("task_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_activity_events_event_type", "activity_events", ["event_type"], unique=False)
op.create_index("ix_activity_events_agent_id", "activity_events", ["agent_id"], unique=False)
op.create_index("ix_activity_events_task_id", "activity_events", ["task_id"], unique=False)
op.create_table(
"board_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column(
"is_chat",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("source", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_memory_board_id", "board_memory", ["board_id"], unique=False)
op.create_index("ix_board_memory_is_chat", "board_memory", ["is_chat"], unique=False)
op.create_index(
"ix_board_memory_board_id_is_chat_created_at",
"board_memory",
["board_id", "is_chat", "created_at"],
unique=False,
)
op.create_table(
"approvals",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=True),
sa.Column("agent_id", sa.Uuid(), nullable=True),
sa.Column("action_type", sa.String(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("confidence", sa.Integer(), nullable=False),
sa.Column("rubric_scores", sa.JSON(), nullable=True),
sa.Column("status", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_approvals_board_id", "approvals", ["board_id"], unique=False)
op.create_index("ix_approvals_agent_id", "approvals", ["agent_id"], unique=False)
op.create_index("ix_approvals_task_id", "approvals", ["task_id"], unique=False)
op.create_index("ix_approvals_status", "approvals", ["status"], unique=False)
op.create_table(
"board_onboarding_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("session_key", sa.String(), nullable=False),
sa.Column(
"status",
sa.String(),
server_default="active",
nullable=False,
),
sa.Column("messages", sa.JSON(), nullable=True),
sa.Column("draft_goal", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_board_onboarding_sessions_board_id",
"board_onboarding_sessions",
["board_id"],
unique=False,
)
op.create_index(
"ix_board_onboarding_sessions_status",
"board_onboarding_sessions",
["status"],
unique=False,
)
op.create_table(
"task_fingerprints",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("fingerprint_hash", sa.String(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_task_fingerprints_board_hash",
"task_fingerprints",
["board_id", "fingerprint_hash"],
unique=True,
)
op.create_index(
"ix_task_fingerprints_board_id",
"task_fingerprints",
["board_id"],
unique=False,
)
op.create_index(
"ix_task_fingerprints_fingerprint_hash",
"task_fingerprints",
["fingerprint_hash"],
unique=False,
)
op.create_index(
"ix_task_fingerprints_task_id",
"task_fingerprints",
["task_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_task_fingerprints_task_id", table_name="task_fingerprints")
op.drop_index(
"ix_task_fingerprints_fingerprint_hash",
table_name="task_fingerprints",
)
op.drop_index("ix_task_fingerprints_board_id", table_name="task_fingerprints")
op.drop_index("ix_task_fingerprints_board_hash", table_name="task_fingerprints")
op.drop_table("task_fingerprints")
op.drop_index(
"ix_board_onboarding_sessions_status",
table_name="board_onboarding_sessions",
)
op.drop_index(
"ix_board_onboarding_sessions_board_id",
table_name="board_onboarding_sessions",
)
op.drop_table("board_onboarding_sessions")
op.drop_index("ix_approvals_status", table_name="approvals")
op.drop_index("ix_approvals_task_id", table_name="approvals")
op.drop_index("ix_approvals_agent_id", table_name="approvals")
op.drop_index("ix_approvals_board_id", table_name="approvals")
op.drop_table("approvals")
op.drop_index(
"ix_board_memory_board_id_is_chat_created_at",
table_name="board_memory",
)
op.drop_index("ix_board_memory_is_chat", table_name="board_memory")
op.drop_index("ix_board_memory_board_id", table_name="board_memory")
op.drop_table("board_memory")
op.drop_index("ix_activity_events_task_id", table_name="activity_events")
op.drop_index("ix_activity_events_agent_id", table_name="activity_events")
op.drop_index("ix_activity_events_event_type", table_name="activity_events")
op.drop_table("activity_events")
op.drop_index("ix_tasks_created_by_user_id", table_name="tasks")
op.drop_index("ix_tasks_assigned_agent_id", table_name="tasks")
op.drop_index("ix_tasks_due_at", table_name="tasks")
op.drop_index("ix_tasks_priority", table_name="tasks")
op.drop_index("ix_tasks_status", table_name="tasks")
op.drop_index("ix_tasks_board_id", table_name="tasks")
op.drop_table("tasks")
op.drop_index("ix_agents_is_board_lead", table_name="agents")
op.drop_index("ix_agents_delete_confirm_token_hash", table_name="agents")
op.drop_index("ix_agents_provision_action", table_name="agents")
op.drop_index("ix_agents_provision_confirm_token_hash", table_name="agents")
op.drop_index("ix_agents_agent_token_hash", table_name="agents")
op.drop_index("ix_agents_openclaw_session_id", table_name="agents")
op.drop_index("ix_agents_status", table_name="agents")
op.drop_index("ix_agents_name", table_name="agents")
op.drop_index("ix_agents_board_id", table_name="agents")
op.drop_table("agents")
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_clerk_user_id", table_name="users")
op.drop_table("users")
op.drop_index("ix_boards_board_type", table_name="boards")
op.drop_index("ix_boards_gateway_id", table_name="boards")
op.drop_index("ix_boards_slug", table_name="boards")
op.drop_table("boards")
op.drop_table("gateways")

View File

@@ -1,259 +0,0 @@
"""add organizations
Revision ID: 1f2a3b4c5d6e
Revises: 9f0c4fb2a7b8
Create Date: 2026-02-07
"""
from __future__ import annotations
from datetime import datetime
import uuid
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1f2a3b4c5d6e"
down_revision = "9f0c4fb2a7b8"
branch_labels = None
depends_on = None
DEFAULT_ORG_NAME = "Personal"
def upgrade() -> None:
op.create_table(
"organizations",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("name", name="uq_organizations_name"),
)
op.create_index("ix_organizations_name", "organizations", ["name"])
op.create_table(
"organization_members",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("role", sa.String(), nullable=False, server_default="member"),
sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_members_org"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_org_members_user"),
sa.UniqueConstraint(
"organization_id",
"user_id",
name="uq_organization_members_org_user",
),
)
op.create_index("ix_org_members_org", "organization_members", ["organization_id"])
op.create_index("ix_org_members_user", "organization_members", ["user_id"])
op.create_index("ix_org_members_role", "organization_members", ["role"])
op.create_table(
"organization_board_access",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_member_id", sa.UUID(), nullable=False),
sa.Column("board_id", sa.UUID(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_member_id"],
["organization_members.id"],
name="fk_org_board_access_member",
),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_board_access_board"),
sa.UniqueConstraint(
"organization_member_id",
"board_id",
name="uq_org_board_access_member_board",
),
)
op.create_index(
"ix_org_board_access_member",
"organization_board_access",
["organization_member_id"],
)
op.create_index(
"ix_org_board_access_board",
"organization_board_access",
["board_id"],
)
op.create_table(
"organization_invites",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column("invited_email", sa.String(), nullable=False),
sa.Column("token", sa.String(), nullable=False),
sa.Column("role", sa.String(), nullable=False, server_default="member"),
sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by_user_id", sa.UUID(), nullable=True),
sa.Column("accepted_by_user_id", sa.UUID(), nullable=True),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_invites_org"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_org_invites_creator"),
sa.ForeignKeyConstraint(["accepted_by_user_id"], ["users.id"], name="fk_org_invites_acceptor"),
sa.UniqueConstraint("token", name="uq_org_invites_token"),
)
op.create_index("ix_org_invites_org", "organization_invites", ["organization_id"])
op.create_index("ix_org_invites_email", "organization_invites", ["invited_email"])
op.create_index("ix_org_invites_token", "organization_invites", ["token"])
op.create_table(
"organization_invite_board_access",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_invite_id", sa.UUID(), nullable=False),
sa.Column("board_id", sa.UUID(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_invite_id"],
["organization_invites.id"],
name="fk_org_invite_access_invite",
),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_invite_access_board"),
sa.UniqueConstraint(
"organization_invite_id",
"board_id",
name="uq_org_invite_board_access_invite_board",
),
)
op.create_index(
"ix_org_invite_access_invite",
"organization_invite_board_access",
["organization_invite_id"],
)
op.create_index(
"ix_org_invite_access_board",
"organization_invite_board_access",
["board_id"],
)
op.add_column("boards", sa.Column("organization_id", sa.UUID(), nullable=True))
op.add_column("board_groups", sa.Column("organization_id", sa.UUID(), nullable=True))
op.add_column("gateways", sa.Column("organization_id", sa.UUID(), nullable=True))
op.create_index("ix_boards_organization_id", "boards", ["organization_id"])
op.create_index("ix_board_groups_organization_id", "board_groups", ["organization_id"])
op.create_index("ix_gateways_organization_id", "gateways", ["organization_id"])
op.create_foreign_key(
"fk_boards_organization_id",
"boards",
"organizations",
["organization_id"],
["id"],
)
op.create_foreign_key(
"fk_board_groups_organization_id",
"board_groups",
"organizations",
["organization_id"],
["id"],
)
op.create_foreign_key(
"fk_gateways_organization_id",
"gateways",
"organizations",
["organization_id"],
["id"],
)
bind = op.get_bind()
now = datetime.utcnow()
org_id = uuid.uuid4()
bind.execute(
sa.text(
"INSERT INTO organizations (id, name, created_at, updated_at) VALUES (:id, :name, :now, :now)"
),
{"id": org_id, "name": DEFAULT_ORG_NAME, "now": now},
)
bind.execute(
sa.text("UPDATE boards SET organization_id = :org_id"),
{"org_id": org_id},
)
bind.execute(
sa.text("UPDATE board_groups SET organization_id = :org_id"),
{"org_id": org_id},
)
bind.execute(
sa.text("UPDATE gateways SET organization_id = :org_id"),
{"org_id": org_id},
)
user_rows = list(bind.execute(sa.text("SELECT id FROM users")))
for row in user_rows:
user_id = row[0]
bind.execute(
sa.text(
"""
INSERT INTO organization_members
(id, organization_id, user_id, role, all_boards_read, all_boards_write, created_at, updated_at)
VALUES
(:id, :org_id, :user_id, :role, :all_read, :all_write, :now, :now)
"""
),
{
"id": uuid.uuid4(),
"org_id": org_id,
"user_id": user_id,
"role": "owner",
"all_read": True,
"all_write": True,
"now": now,
},
)
op.alter_column("boards", "organization_id", nullable=False)
op.alter_column("board_groups", "organization_id", nullable=False)
op.alter_column("gateways", "organization_id", nullable=False)
def downgrade() -> None:
op.drop_constraint("fk_gateways_organization_id", "gateways", type_="foreignkey")
op.drop_constraint("fk_board_groups_organization_id", "board_groups", type_="foreignkey")
op.drop_constraint("fk_boards_organization_id", "boards", type_="foreignkey")
op.drop_index("ix_gateways_organization_id", table_name="gateways")
op.drop_index("ix_board_groups_organization_id", table_name="board_groups")
op.drop_index("ix_boards_organization_id", table_name="boards")
op.drop_column("gateways", "organization_id")
op.drop_column("board_groups", "organization_id")
op.drop_column("boards", "organization_id")
op.drop_index("ix_org_invite_access_board", table_name="organization_invite_board_access")
op.drop_index("ix_org_invite_access_invite", table_name="organization_invite_board_access")
op.drop_table("organization_invite_board_access")
op.drop_index("ix_org_invites_token", table_name="organization_invites")
op.drop_index("ix_org_invites_email", table_name="organization_invites")
op.drop_index("ix_org_invites_org", table_name="organization_invites")
op.drop_table("organization_invites")
op.drop_index("ix_org_board_access_board", table_name="organization_board_access")
op.drop_index("ix_org_board_access_member", table_name="organization_board_access")
op.drop_table("organization_board_access")
op.drop_index("ix_org_members_role", table_name="organization_members")
op.drop_index("ix_org_members_user", table_name="organization_members")
op.drop_index("ix_org_members_org", table_name="organization_members")
op.drop_table("organization_members")
op.drop_index("ix_organizations_name", table_name="organizations")
op.drop_table("organizations")

View File

@@ -1,122 +0,0 @@
"""board group memory
Revision ID: 23c771c93430
Revises: 12772fdcdfe9
Create Date: 2026-02-07 18:00:19.065861
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision = "23c771c93430"
down_revision = "12772fdcdfe9"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Repair drift: it's possible to end up with alembic_version stamped at 12772fdcdfe9
# without actually applying the board groups schema changes. This migration makes the
# required board_groups + boards.board_group_id objects exist before adding group memory.
conn = op.get_bind()
inspector = sa.inspect(conn)
if not inspector.has_table("board_groups"):
op.create_table(
"board_groups",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False)
else:
indexes = {idx.get("name") for idx in inspector.get_indexes("board_groups")}
if "ix_board_groups_slug" not in indexes:
op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False)
inspector = sa.inspect(conn)
board_cols = {col.get("name") for col in inspector.get_columns("boards")}
if "board_group_id" not in board_cols:
op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True))
inspector = sa.inspect(conn)
board_indexes = {idx.get("name") for idx in inspector.get_indexes("boards")}
if "ix_boards_board_group_id" not in board_indexes:
op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False)
def _has_board_groups_fk() -> bool:
for fk in inspector.get_foreign_keys("boards"):
if fk.get("referred_table") != "board_groups":
continue
if fk.get("constrained_columns") != ["board_group_id"]:
continue
if fk.get("referred_columns") != ["id"]:
continue
return True
return False
if not _has_board_groups_fk():
op.create_foreign_key(
"fk_boards_board_group_id_board_groups",
"boards",
"board_groups",
["board_group_id"],
["id"],
)
inspector = sa.inspect(conn)
if not inspector.has_table("board_group_memory"):
op.create_table(
"board_group_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_group_id", sa.Uuid(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column(
"is_chat",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("source", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_group_id"], ["board_groups.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_board_group_memory_board_group_id",
"board_group_memory",
["board_group_id"],
unique=False,
)
op.create_index(
"ix_board_group_memory_is_chat",
"board_group_memory",
["is_chat"],
unique=False,
)
op.create_index(
"ix_board_group_memory_board_group_id_is_chat_created_at",
"board_group_memory",
["board_group_id", "is_chat", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_board_group_memory_board_group_id_is_chat_created_at",
table_name="board_group_memory",
)
op.drop_index("ix_board_group_memory_is_chat", table_name="board_group_memory")
op.drop_index("ix_board_group_memory_board_group_id", table_name="board_group_memory")
op.drop_table("board_group_memory")

View File

@@ -1,24 +0,0 @@
"""merge heads
Revision ID: 2c7b1c4d9e10
Revises: 1f2a3b4c5d6e, af403671a8c4
Create Date: 2026-02-07
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = "2c7b1c4d9e10"
down_revision = ("1f2a3b4c5d6e", "af403671a8c4")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -1,80 +0,0 @@
"""task dependencies
Revision ID: 3c6a2d3df4a1
Revises: 1d844b04ee06
Create Date: 2026-02-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "3c6a2d3df4a1"
down_revision = "1d844b04ee06"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"task_dependencies",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("depends_on_task_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["task_id"],
["tasks.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["depends_on_task_id"],
["tasks.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"task_id",
"depends_on_task_id",
name="uq_task_dependencies_task_id_depends_on_task_id",
),
sa.CheckConstraint(
"task_id <> depends_on_task_id",
name="ck_task_dependencies_no_self",
),
)
op.create_index(
"ix_task_dependencies_board_id",
"task_dependencies",
["board_id"],
unique=False,
)
op.create_index(
"ix_task_dependencies_task_id",
"task_dependencies",
["task_id"],
unique=False,
)
op.create_index(
"ix_task_dependencies_depends_on_task_id",
"task_dependencies",
["depends_on_task_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_task_dependencies_depends_on_task_id", table_name="task_dependencies")
op.drop_index("ix_task_dependencies_task_id", table_name="task_dependencies")
op.drop_index("ix_task_dependencies_board_id", table_name="task_dependencies")
op.drop_table("task_dependencies")

View File

@@ -1,67 +0,0 @@
"""ensure board group memory table
Revision ID: 5fb3b2491090
Revises: 23c771c93430
Create Date: 2026-02-07 18:07:20.588662
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "5fb3b2491090"
down_revision = "23c771c93430"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if inspector.has_table("board_group_memory"):
return
op.create_table(
"board_group_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_group_id", sa.Uuid(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column(
"is_chat",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column("source", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_group_id"], ["board_groups.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_board_group_memory_board_group_id",
"board_group_memory",
["board_group_id"],
unique=False,
)
op.create_index(
"ix_board_group_memory_is_chat",
"board_group_memory",
["is_chat"],
unique=False,
)
op.create_index(
"ix_board_group_memory_board_group_id_is_chat_created_at",
"board_group_memory",
["board_group_id", "is_chat", "created_at"],
unique=False,
)
def downgrade() -> None:
# This is a repair migration. Downgrading from 5fb3b2491090 -> 23c771c93430
# should keep the board_group_memory table (it belongs to the prior revision).
return

View File

@@ -1,70 +0,0 @@
"""add active organization to users
Revision ID: 6e1c9b2f7a4d
Revises: 050c16fde00e
Create Date: 2026-02-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "6e1c9b2f7a4d"
down_revision = "050c16fde00e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("active_organization_id", sa.UUID(), nullable=True),
)
op.create_index(
"ix_users_active_organization_id",
"users",
["active_organization_id"],
)
op.create_foreign_key(
"fk_users_active_organization",
"users",
"organizations",
["active_organization_id"],
["id"],
)
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT user_id, organization_id
FROM organization_members
ORDER BY user_id, created_at ASC
"""
)
).fetchall()
seen: set[str] = set()
for row in rows:
user_id = str(row.user_id)
if user_id in seen:
continue
seen.add(user_id)
bind.execute(
sa.text(
"""
UPDATE users
SET active_organization_id = :org_id
WHERE id = :user_id
AND active_organization_id IS NULL
"""
),
{"org_id": row.organization_id, "user_id": row.user_id},
)
def downgrade() -> None:
op.drop_constraint("fk_users_active_organization", "users", type_="foreignkey")
op.drop_index("ix_users_active_organization_id", table_name="users")
op.drop_column("users", "active_organization_id")

View File

@@ -1,34 +0,0 @@
"""remove skyll_enabled
Revision ID: 9f0c4fb2a7b8
Revises: 3c6a2d3df4a1
Create Date: 2026-02-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9f0c4fb2a7b8"
down_revision = "3c6a2d3df4a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("gateways", "skyll_enabled")
def downgrade() -> None:
op.add_column(
"gateways",
sa.Column(
"skyll_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)

View File

@@ -1,79 +0,0 @@
"""repair board groups schema
Revision ID: af403671a8c4
Revises: 5fb3b2491090
Create Date: 2026-02-07
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision = "af403671a8c4"
down_revision = "5fb3b2491090"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Repair drift: it is possible to end up with alembic_version stamped at (or beyond)
# the board group revisions without having the underlying DB objects present.
conn = op.get_bind()
inspector = sa.inspect(conn)
if not inspector.has_table("board_groups"):
op.create_table(
"board_groups",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False)
else:
indexes = {idx.get("name") for idx in inspector.get_indexes("board_groups")}
if "ix_board_groups_slug" not in indexes:
op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False)
inspector = sa.inspect(conn)
board_cols = {col.get("name") for col in inspector.get_columns("boards")}
if "board_group_id" not in board_cols:
op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True))
inspector = sa.inspect(conn)
board_indexes = {idx.get("name") for idx in inspector.get_indexes("boards")}
if "ix_boards_board_group_id" not in board_indexes:
op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False)
def _has_board_groups_fk() -> bool:
for fk in inspector.get_foreign_keys("boards"):
if fk.get("referred_table") != "board_groups":
continue
if fk.get("constrained_columns") != ["board_group_id"]:
continue
if fk.get("referred_columns") != ["id"]:
continue
return True
return False
if not _has_board_groups_fk():
op.create_foreign_key(
"fk_boards_board_group_id_board_groups",
"boards",
"board_groups",
["board_group_id"],
["id"],
)
def downgrade() -> None:
# Repair migration: do not attempt to undo drift fixes automatically.
return

View File

@@ -21,6 +21,7 @@ from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.db.sqlmodel_exec import exec_dml
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent
@@ -973,7 +974,8 @@ async def delete_agent(
agent_id=None,
)
now = datetime.now()
await session.execute(
await exec_dml(
session,
update(Task)
.where(col(Task.assigned_agent_id) == agent.id)
.where(col(Task.status) == "in_progress")
@@ -982,19 +984,21 @@ async def delete_agent(
status="inbox",
in_progress_at=None,
updated_at=now,
),
)
)
await session.execute(
await exec_dml(
session,
update(Task)
.where(col(Task.assigned_agent_id) == agent.id)
.where(col(Task.status) != "in_progress")
.values(
assigned_agent_id=None,
updated_at=now,
),
)
)
await session.execute(
update(ActivityEvent).where(col(ActivityEvent.agent_id) == agent.id).values(agent_id=None)
await exec_dml(
session,
update(ActivityEvent).where(col(ActivityEvent.agent_id) == agent.id).values(agent_id=None),
)
await session.delete(agent)
await session.commit()

View File

@@ -14,6 +14,7 @@ from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.db.sqlmodel_exec import exec_dml
from app.models.agents import Agent
from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
@@ -262,10 +263,8 @@ async def update_board_group(
updates = payload.model_dump(exclude_unset=True)
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
updates["slug"] = _slugify(updates.get("name") or group.name)
for key, value in updates.items():
setattr(group, key, value)
group.updated_at = utcnow()
return await crud.save(session, group)
updates["updated_at"] = utcnow()
return await crud.patch(session, group, updates)
@router.delete("/{group_id}", response_model=OkResponse)
@@ -277,12 +276,14 @@ async def delete_board_group(
await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
# Boards reference groups, so clear the FK first to keep deletes simple.
await session.execute(
update(Board).where(col(Board.board_group_id) == group_id).values(board_group_id=None)
await exec_dml(
session,
update(Board).where(col(Board.board_group_id) == group_id).values(board_group_id=None),
)
await session.execute(
delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id)
await exec_dml(
session,
delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id),
)
await session.execute(delete(BoardGroup).where(col(BoardGroup.id) == group_id))
await exec_dml(session, delete(BoardGroup).where(col(BoardGroup.id) == group_id))
await session.commit()
return OkResponse()

View File

@@ -19,6 +19,7 @@ from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.db.sqlmodel_exec import exec_dml
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
@@ -140,8 +141,7 @@ async def _apply_board_update(
updates["board_group_id"],
organization_id=board.organization_id,
)
for key, value in updates.items():
setattr(board, key, value)
crud.apply_updates(board, updates)
if updates.get("board_type") == "goal":
# Validate only when explicitly switching to goal boards.
if not board.objective or not board.success_metrics:
@@ -307,36 +307,43 @@ async def delete_board(
) from exc
if task_ids:
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id) == board.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id))
await exec_dml(
session, delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))
)
await exec_dml(session, delete(TaskDependency).where(col(TaskDependency.board_id) == board.id))
await exec_dml(
session, delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id)
)
# Approvals can reference tasks and agents, so delete before both.
await session.execute(delete(Approval).where(col(Approval.board_id) == board.id))
await exec_dml(session, delete(Approval).where(col(Approval.board_id) == board.id))
await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id))
await session.execute(
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id)
await exec_dml(session, delete(BoardMemory).where(col(BoardMemory.board_id) == board.id))
await exec_dml(
session,
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id),
)
await session.execute(
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id)
await exec_dml(
session,
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id),
)
await session.execute(
await exec_dml(
session,
delete(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.board_id) == board.id
)
),
)
# Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so
# delete tasks before agents.
await session.execute(delete(Task).where(col(Task.board_id) == board.id))
await exec_dml(session, delete(Task).where(col(Task.board_id) == board.id))
if agents:
agent_ids = [agent.id for agent in agents]
await session.execute(
delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))
await exec_dml(
session, delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))
)
await session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids)))
await exec_dml(session, delete(Agent).where(col(Agent.id).in_(agent_ids)))
await session.delete(board)
await session.commit()
return OkResponse()

View File

@@ -2,14 +2,16 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, Query
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.api.queryset import api_qs
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
@@ -35,6 +37,22 @@ def _main_agent_name(gateway: Gateway) -> str:
return f"{gateway.name} Main"
async def _require_gateway(
session: AsyncSession,
*,
gateway_id: UUID,
organization_id: UUID,
) -> Gateway:
return await (
api_qs(Gateway)
.filter(
col(Gateway.id) == gateway_id,
col(Gateway.organization_id) == organization_id,
)
.first_or_404(session, detail="Gateway not found")
)
async def _find_main_agent(
session: AsyncSession,
gateway: Gateway,
@@ -135,9 +153,10 @@ async def list_gateways(
ctx: OrganizationContext = Depends(require_org_admin),
) -> DefaultLimitOffsetPage[GatewayRead]:
statement = (
select(Gateway)
.where(col(Gateway.organization_id) == ctx.organization.id)
api_qs(Gateway)
.filter(col(Gateway.organization_id) == ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
.statement
)
return await paginate(session, statement)
@@ -151,10 +170,7 @@ async def create_gateway(
) -> Gateway:
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
gateway = Gateway.model_validate(data)
session.add(gateway)
await session.commit()
await session.refresh(gateway)
gateway = await crud.create(session, Gateway, **data)
await _ensure_main_agent(session, gateway, auth, action="provision")
return gateway
@@ -165,10 +181,11 @@ async def get_gateway(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return gateway
return await _require_gateway(
session,
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
@router.patch("/{gateway_id}", response_model=GatewayRead)
@@ -179,17 +196,15 @@ async def update_gateway(
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
gateway = await _require_gateway(
session,
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
previous_name = gateway.name
previous_session_key = gateway.main_session_key
updates = payload.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(gateway, key, value)
session.add(gateway)
await session.commit()
await session.refresh(gateway)
await crud.patch(session, gateway, updates)
await _ensure_main_agent(
session,
gateway,
@@ -213,9 +228,11 @@ async def sync_gateway_templates(
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewayTemplatesSyncResult:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
gateway = await _require_gateway(
session,
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await sync_gateway_templates_service(
session,
gateway,
@@ -234,9 +251,10 @@ async def delete_gateway(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
await session.delete(gateway)
await session.commit()
gateway = await _require_gateway(
session,
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
await crud.delete(session, gateway)
return OkResponse()

View File

@@ -10,10 +10,13 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin, require_org_member
from app.api.queryset import api_qs
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.db.sqlmodel_exec import exec_dml
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
@@ -72,6 +75,38 @@ def _member_to_read(member: OrganizationMember, user: User | None) -> Organizati
return model
async def _require_org_member(
session: AsyncSession,
*,
organization_id: UUID,
member_id: UUID,
) -> OrganizationMember:
return await (
api_qs(OrganizationMember)
.filter(
col(OrganizationMember.id) == member_id,
col(OrganizationMember.organization_id) == organization_id,
)
.first_or_404(session)
)
async def _require_org_invite(
session: AsyncSession,
*,
organization_id: UUID,
invite_id: UUID,
) -> OrganizationInvite:
return await (
api_qs(OrganizationInvite)
.filter(
col(OrganizationInvite.id) == invite_id,
col(OrganizationInvite.organization_id) == organization_id,
)
.first_or_404(session)
)
@router.post("", response_model=OrganizationRead)
async def create_organization(
payload: OrganizationCreate,
@@ -188,55 +223,67 @@ async def delete_my_org(
)
group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id)
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)))
await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id).in_(board_ids)))
await session.execute(
delete(TaskFingerprint).where(col(TaskFingerprint.board_id).in_(board_ids))
await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)))
await exec_dml(
session, delete(TaskDependency).where(col(TaskDependency.board_id).in_(board_ids))
)
await session.execute(delete(Approval).where(col(Approval.board_id).in_(board_ids)))
await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id).in_(board_ids)))
await session.execute(
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id).in_(board_ids))
await exec_dml(
session,
delete(TaskFingerprint).where(col(TaskFingerprint.board_id).in_(board_ids)),
)
await session.execute(
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id).in_(board_ids))
await exec_dml(session, delete(Approval).where(col(Approval.board_id).in_(board_ids)))
await exec_dml(session, delete(BoardMemory).where(col(BoardMemory.board_id).in_(board_ids)))
await exec_dml(
session,
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id).in_(board_ids)),
)
await session.execute(
await exec_dml(
session,
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id).in_(board_ids)),
)
await exec_dml(
session,
delete(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.board_id).in_(board_ids)
),
)
)
await session.execute(
await exec_dml(
session,
delete(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id).in_(member_ids)
),
)
)
await session.execute(
await exec_dml(
session,
delete(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids)
),
)
await exec_dml(session, delete(Task).where(col(Task.board_id).in_(board_ids)))
await exec_dml(session, delete(Agent).where(col(Agent.board_id).in_(board_ids)))
await exec_dml(session, delete(Board).where(col(Board.organization_id) == org_id))
await exec_dml(
session,
delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id).in_(group_ids)),
)
await session.execute(delete(Task).where(col(Task.board_id).in_(board_ids)))
await session.execute(delete(Agent).where(col(Agent.board_id).in_(board_ids)))
await session.execute(delete(Board).where(col(Board.organization_id) == org_id))
await session.execute(
delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id).in_(group_ids))
await exec_dml(session, delete(BoardGroup).where(col(BoardGroup.organization_id) == org_id))
await exec_dml(session, delete(Gateway).where(col(Gateway.organization_id) == org_id))
await exec_dml(
session,
delete(OrganizationInvite).where(col(OrganizationInvite.organization_id) == org_id),
)
await session.execute(delete(BoardGroup).where(col(BoardGroup.organization_id) == org_id))
await session.execute(delete(Gateway).where(col(Gateway.organization_id) == org_id))
await session.execute(
delete(OrganizationInvite).where(col(OrganizationInvite.organization_id) == org_id)
await exec_dml(
session,
delete(OrganizationMember).where(col(OrganizationMember.organization_id) == org_id),
)
await session.execute(
delete(OrganizationMember).where(col(OrganizationMember.organization_id) == org_id)
)
await session.execute(
await exec_dml(
session,
update(User)
.where(col(User.active_organization_id) == org_id)
.values(active_organization_id=None)
.values(active_organization_id=None),
)
await session.execute(delete(Organization).where(col(Organization.id) == org_id))
await exec_dml(session, delete(Organization).where(col(Organization.id) == org_id))
await session.commit()
return OkResponse()
@@ -288,9 +335,11 @@ async def get_org_member(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
member = await _require_org_member(
session,
organization_id=ctx.organization.id,
member_id=member_id,
)
if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
user = await session.get(User, member.user_id)
@@ -315,16 +364,16 @@ async def update_org_member(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
member = await _require_org_member(
session,
organization_id=ctx.organization.id,
member_id=member_id,
)
updates = payload.model_dump(exclude_unset=True)
if "role" in updates and updates["role"] is not None:
member.role = normalize_role(updates["role"])
member.updated_at = utcnow()
session.add(member)
await session.commit()
await session.refresh(member)
updates["role"] = normalize_role(updates["role"])
updates["updated_at"] = utcnow()
member = await crud.patch(session, member, updates)
user = await session.get(User, member.user_id)
return _member_to_read(member, user)
@@ -336,9 +385,11 @@ async def update_member_access(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
member = await _require_org_member(
session,
organization_id=ctx.organization.id,
member_id=member_id,
)
board_ids = {entry.board_id for entry in payload.board_access}
if board_ids:
@@ -393,10 +444,11 @@ async def remove_org_member(
detail="Organization must have at least one owner",
)
await session.execute(
await exec_dml(
session,
delete(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id
)
),
)
user = await session.get(User, member.user_id)
@@ -412,8 +464,7 @@ async def remove_org_member(
user.active_organization_id = fallback_org_id
session.add(user)
await session.delete(member)
await session.commit()
await crud.delete(session, member)
return OkResponse()
@@ -423,10 +474,11 @@ async def list_org_invites(
ctx: OrganizationContext = Depends(require_org_admin),
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
statement = (
select(OrganizationInvite)
.where(col(OrganizationInvite.organization_id) == ctx.organization.id)
.where(col(OrganizationInvite.accepted_at).is_(None))
api_qs(OrganizationInvite)
.filter(col(OrganizationInvite.organization_id) == ctx.organization.id)
.filter(col(OrganizationInvite.accepted_at).is_(None))
.order_by(col(OrganizationInvite.created_at).desc())
.statement
)
return await paginate(session, statement)
@@ -491,16 +543,18 @@ async def revoke_org_invite(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationInviteRead:
invite = await session.get(OrganizationInvite, invite_id)
if invite is None or invite.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await session.execute(
invite = await _require_org_invite(
session,
organization_id=ctx.organization.id,
invite_id=invite_id,
)
await exec_dml(
session,
delete(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
),
)
await session.delete(invite)
await session.commit()
await crud.delete(session, invite)
return OrganizationInviteRead.model_validate(invite, from_attributes=True)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from fastapi import HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
from app.db.queryset import QuerySet, qs
ModelT = TypeVar("ModelT")
@dataclass(frozen=True)
class APIQuerySet(Generic[ModelT]):
queryset: QuerySet[ModelT]
@property
def statement(self) -> SelectOfScalar[ModelT]:
return self.queryset.statement
def filter(self, *criteria: Any) -> APIQuerySet[ModelT]:
return APIQuerySet(self.queryset.filter(*criteria))
def order_by(self, *ordering: Any) -> APIQuerySet[ModelT]:
return APIQuerySet(self.queryset.order_by(*ordering))
def limit(self, value: int) -> APIQuerySet[ModelT]:
return APIQuerySet(self.queryset.limit(value))
def offset(self, value: int) -> APIQuerySet[ModelT]:
return APIQuerySet(self.queryset.offset(value))
async def all(self, session: AsyncSession) -> list[ModelT]:
return await self.queryset.all(session)
async def first(self, session: AsyncSession) -> ModelT | None:
return await self.queryset.first(session)
async def first_or_404(
self,
session: AsyncSession,
*,
detail: str | None = None,
) -> ModelT:
obj = await self.first(session)
if obj is not None:
return obj
if detail is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
def api_qs(model: type[ModelT]) -> APIQuerySet[ModelT]:
return APIQuerySet(qs(model))

View File

@@ -27,6 +27,7 @@ from app.core.auth import AuthContext
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.db.sqlmodel_exec import exec_dml
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent
@@ -990,16 +991,17 @@ async def delete_task(
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
await session.execute(delete(Approval).where(col(Approval.task_id) == task.id))
await session.execute(
await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await exec_dml(session, delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
await exec_dml(session, delete(Approval).where(col(Approval.task_id) == task.id))
await exec_dml(
session,
delete(TaskDependency).where(
or_(
col(TaskDependency.task_id) == task.id,
col(TaskDependency.depends_on_task_id) == task.id,
)
)
),
)
await session.delete(task)
await session.commit()

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from typing import Any, TypeVar
from sqlalchemy.exc import IntegrityError
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
ModelT = TypeVar("ModelT", bound=SQLModel)
@@ -18,15 +19,19 @@ class MultipleObjectsReturned(LookupError):
pass
def _lookup_statement(model: type[ModelT], lookup: Mapping[str, Any]) -> SelectOfScalar[ModelT]:
stmt = select(model)
for key, value in lookup.items():
stmt = stmt.where(getattr(model, key) == value)
return stmt
async def get_by_id(session: AsyncSession, model: type[ModelT], obj_id: Any) -> ModelT | None:
return await session.get(model, obj_id)
async def get(session: AsyncSession, model: type[ModelT], **lookup: Any) -> ModelT:
stmt = select(model)
for key, value in lookup.items():
stmt = stmt.where(getattr(model, key) == value)
stmt = stmt.limit(2)
stmt = _lookup_statement(model, lookup).limit(2)
items = (await session.exec(stmt)).all()
if not items:
raise DoesNotExist(f"{model.__name__} matching query does not exist.")
@@ -38,9 +43,7 @@ async def get(session: AsyncSession, model: type[ModelT], **lookup: Any) -> Mode
async def get_one_by(session: AsyncSession, model: type[ModelT], **lookup: Any) -> ModelT | None:
stmt = select(model)
for key, value in lookup.items():
stmt = stmt.where(getattr(model, key) == value)
stmt = _lookup_statement(model, lookup)
return (await session.exec(stmt)).first()
@@ -84,6 +87,64 @@ async def delete(session: AsyncSession, obj: ModelT, *, commit: bool = True) ->
await session.commit()
async def list_by(
session: AsyncSession,
model: type[ModelT],
*,
order_by: Iterable[Any] = (),
limit: int | None = None,
offset: int | None = None,
**lookup: Any,
) -> list[ModelT]:
stmt = _lookup_statement(model, lookup)
for ordering in order_by:
stmt = stmt.order_by(ordering)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
return list(await session.exec(stmt))
async def exists(session: AsyncSession, model: type[ModelT], **lookup: Any) -> bool:
return (await session.exec(_lookup_statement(model, lookup).limit(1))).first() is not None
def apply_updates(
obj: ModelT,
updates: Mapping[str, Any],
*,
exclude_none: bool = False,
allowed_fields: set[str] | None = None,
) -> ModelT:
for key, value in updates.items():
if allowed_fields is not None and key not in allowed_fields:
continue
if exclude_none and value is None:
continue
setattr(obj, key, value)
return obj
async def patch(
session: AsyncSession,
obj: ModelT,
updates: Mapping[str, Any],
*,
exclude_none: bool = False,
allowed_fields: set[str] | None = None,
commit: bool = True,
refresh: bool = True,
) -> ModelT:
apply_updates(
obj,
updates,
exclude_none=exclude_none,
allowed_fields=allowed_fields,
)
return await save(session, obj, commit=commit, refresh=refresh)
async def get_or_create(
session: AsyncSession,
model: type[ModelT],
@@ -93,9 +154,7 @@ async def get_or_create(
refresh: bool = True,
**lookup: Any,
) -> tuple[ModelT, bool]:
stmt = select(model)
for key, value in lookup.items():
stmt = stmt.where(getattr(model, key) == value)
stmt = _lookup_statement(model, lookup)
existing = (await session.exec(stmt)).first()
if existing is not None:

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any, Generic, TypeVar
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
ModelT = TypeVar("ModelT")
@dataclass(frozen=True)
class QuerySet(Generic[ModelT]):
statement: SelectOfScalar[ModelT]
def filter(self, *criteria: Any) -> QuerySet[ModelT]:
return replace(self, statement=self.statement.where(*criteria))
def order_by(self, *ordering: Any) -> QuerySet[ModelT]:
return replace(self, statement=self.statement.order_by(*ordering))
def limit(self, value: int) -> QuerySet[ModelT]:
return replace(self, statement=self.statement.limit(value))
def offset(self, value: int) -> QuerySet[ModelT]:
return replace(self, statement=self.statement.offset(value))
async def all(self, session: AsyncSession) -> list[ModelT]:
return list(await session.exec(self.statement))
async def first(self, session: AsyncSession) -> ModelT | None:
return (await session.exec(self.statement)).first()
async def one_or_none(self, session: AsyncSession) -> ModelT | None:
return (await session.exec(self.statement)).one_or_none()
async def exists(self, session: AsyncSession) -> bool:
return await self.limit(1).first(session) is not None
def qs(model: type[ModelT]) -> QuerySet[ModelT]:
return QuerySet(select(model))

View File

@@ -5,12 +5,12 @@ from collections.abc import AsyncGenerator
from pathlib import Path
import anyio
from alembic import command
from alembic.config import Config
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from alembic import command
from alembic.config import Config
from app import models # noqa: F401
from app.core.config import settings
@@ -51,12 +51,12 @@ def run_migrations() -> None:
async def init_db() -> None:
if settings.db_auto_migrate:
versions_dir = Path(__file__).resolve().parents[2] / "alembic" / "versions"
versions_dir = Path(__file__).resolve().parents[2] / "migrations" / "versions"
if any(versions_dir.glob("*.py")):
logger.info("Running Alembic migrations on startup")
logger.info("Running migrations on startup")
await anyio.to_thread.run_sync(run_migrations)
return
logger.warning("No Alembic revisions found; falling back to create_all")
logger.warning("No migration revisions found; falling back to create_all")
async with async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from sqlalchemy.sql.base import Executable
from sqlmodel.ext.asyncio.session import AsyncSession
async def exec_dml(session: AsyncSession, statement: Executable) -> None:
# SQLModel's AsyncSession typing only overloads exec() for SELECT statements.
await session.exec(statement) # type: ignore[call-overload]

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from uuid import UUID
from sqlmodel import col
from app.db.queryset import QuerySet, qs
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
def organization_by_name(name: str) -> QuerySet[Organization]:
return qs(Organization).filter(col(Organization.name) == name)
def member_by_user_and_org(*, user_id: UUID, organization_id: UUID) -> QuerySet[OrganizationMember]:
return qs(OrganizationMember).filter(
col(OrganizationMember.organization_id) == organization_id,
col(OrganizationMember.user_id) == user_id,
)
def first_membership_for_user(user_id: UUID) -> QuerySet[OrganizationMember]:
return (
qs(OrganizationMember)
.filter(col(OrganizationMember.user_id) == user_id)
.order_by(col(OrganizationMember.created_at).asc())
)
def pending_invite_by_email(email: str) -> QuerySet[OrganizationInvite]:
return (
qs(OrganizationInvite)
.filter(col(OrganizationInvite.accepted_at).is_(None))
.filter(col(OrganizationInvite.invited_email) == email)
.order_by(col(OrganizationInvite.created_at).asc())
)
def board_access_for_member_and_board(
*,
organization_member_id: UUID,
board_id: UUID,
) -> QuerySet[OrganizationBoardAccess]:
return qs(OrganizationBoardAccess).filter(
col(OrganizationBoardAccess.organization_member_id) == organization_member_id,
col(OrganizationBoardAccess.board_id) == board_id,
)

View File

@@ -11,6 +11,7 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.time import utcnow
from app.db.sqlmodel_exec import exec_dml
from app.models.boards import Board
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
@@ -18,6 +19,7 @@ from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.users import User
from app.queries import organizations as org_queries
from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate
DEFAULT_ORG_NAME = "Personal"
@@ -36,8 +38,7 @@ def is_org_admin(member: OrganizationMember) -> bool:
async def get_default_org(session: AsyncSession) -> Organization | None:
statement = select(Organization).where(col(Organization.name) == DEFAULT_ORG_NAME)
return (await session.exec(statement)).first()
return await org_queries.organization_by_name(DEFAULT_ORG_NAME).first(session)
async def ensure_default_org(session: AsyncSession) -> Organization:
@@ -57,20 +58,14 @@ async def get_member(
user_id: UUID,
organization_id: UUID,
) -> OrganizationMember | None:
statement = select(OrganizationMember).where(
col(OrganizationMember.organization_id) == organization_id,
col(OrganizationMember.user_id) == user_id,
)
return (await session.exec(statement)).first()
return await org_queries.member_by_user_and_org(
user_id=user_id,
organization_id=organization_id,
).first(session)
async def get_first_membership(session: AsyncSession, user_id: UUID) -> OrganizationMember | None:
statement = (
select(OrganizationMember)
.where(col(OrganizationMember.user_id) == user_id)
.order_by(col(OrganizationMember.created_at).asc())
)
return (await session.exec(statement)).first()
return await org_queries.first_membership_for_user(user_id).first(session)
async def set_active_organization(
@@ -124,13 +119,7 @@ async def _find_pending_invite(
session: AsyncSession,
email: str,
) -> OrganizationInvite | None:
statement = (
select(OrganizationInvite)
.where(col(OrganizationInvite.accepted_at).is_(None))
.where(col(OrganizationInvite.invited_email) == email)
.order_by(col(OrganizationInvite.created_at).asc())
)
return (await session.exec(statement)).first()
return await org_queries.pending_invite_by_email(email).first(session)
async def accept_invite(
@@ -241,11 +230,10 @@ async def has_board_access(
else:
if member_all_boards_read(member):
return True
statement = select(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id,
col(OrganizationBoardAccess.board_id) == board.id,
)
access = (await session.exec(statement)).first()
access = await org_queries.board_access_for_member_and_board(
organization_member_id=member.id,
board_id=board.id,
).first(session)
if access is None:
return False
if write:
@@ -330,7 +318,8 @@ async def apply_member_access_update(
member.updated_at = now
session.add(member)
await session.execute(
await exec_dml(
session,
delete(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id
),
@@ -360,7 +349,8 @@ async def apply_invite_board_access(
invite: OrganizationInvite,
entries: Iterable[OrganizationBoardAccessSpec],
) -> None:
await session.execute(
await exec_dml(
session,
delete(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
),

View File

@@ -10,6 +10,7 @@ from sqlalchemy import delete
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.db.sqlmodel_exec import exec_dml
from app.models.task_dependencies import TaskDependency
from app.models.tasks import Task
@@ -194,10 +195,11 @@ async def replace_task_dependencies(
task_id=task_id,
depends_on_task_ids=depends_on_task_ids,
)
await session.execute(
await exec_dml(
session,
delete(TaskDependency)
.where(col(TaskDependency.board_id) == board_id)
.where(col(TaskDependency.task_id) == task_id)
.where(col(TaskDependency.task_id) == task_id),
)
for dep_id in normalized:
session.add(

View File

@@ -0,0 +1,678 @@
"""init
Revision ID: 658dca8f4a11
Revises:
Create Date: 2026-02-09 00:41:55.760624
"""
from __future__ import annotations
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "658dca8f4a11"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"organizations",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_organizations_name"),
)
op.create_index(op.f("ix_organizations_name"), "organizations", ["name"], unique=False)
op.create_table(
"board_groups",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_board_groups_organization_id"), "board_groups", ["organization_id"], unique=False
)
op.create_index(op.f("ix_board_groups_slug"), "board_groups", ["slug"], unique=False)
op.create_table(
"gateways",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("main_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("workspace_root", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_gateways_organization_id"), "gateways", ["organization_id"], unique=False
)
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("clerk_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("preferred_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("pronouns", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("timezone", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("context", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("is_super_admin", sa.Boolean(), nullable=False),
sa.Column("active_organization_id", sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(
["active_organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_users_active_organization_id"), "users", ["active_organization_id"], unique=False
)
op.create_index(op.f("ix_users_clerk_user_id"), "users", ["clerk_user_id"], unique=True)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False)
op.create_table(
"board_group_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_group_id", sa.Uuid(), nullable=False),
sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column("is_chat", sa.Boolean(), nullable=False),
sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_group_id"],
["board_groups.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_board_group_memory_board_group_id"),
"board_group_memory",
["board_group_id"],
unique=False,
)
op.create_index(
op.f("ix_board_group_memory_is_chat"), "board_group_memory", ["is_chat"], unique=False
)
op.create_table(
"boards",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("gateway_id", sa.Uuid(), nullable=True),
sa.Column("board_group_id", sa.Uuid(), nullable=True),
sa.Column("board_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("objective", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("success_metrics", sa.JSON(), nullable=True),
sa.Column("target_date", sa.DateTime(), nullable=True),
sa.Column("goal_confirmed", sa.Boolean(), nullable=False),
sa.Column("goal_source", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_group_id"],
["board_groups.id"],
),
sa.ForeignKeyConstraint(
["gateway_id"],
["gateways.id"],
),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_boards_board_group_id"), "boards", ["board_group_id"], unique=False)
op.create_index(op.f("ix_boards_board_type"), "boards", ["board_type"], unique=False)
op.create_index(op.f("ix_boards_gateway_id"), "boards", ["gateway_id"], unique=False)
op.create_index(op.f("ix_boards_organization_id"), "boards", ["organization_id"], unique=False)
op.create_index(op.f("ix_boards_slug"), "boards", ["slug"], unique=False)
op.create_table(
"organization_invites",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("invited_email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("all_boards_read", sa.Boolean(), nullable=False),
sa.Column("all_boards_write", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.Uuid(), nullable=True),
sa.Column("accepted_by_user_id", sa.Uuid(), nullable=True),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["accepted_by_user_id"],
["users.id"],
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["users.id"],
),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token", name="uq_org_invites_token"),
)
op.create_index(
op.f("ix_organization_invites_accepted_by_user_id"),
"organization_invites",
["accepted_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_invites_created_by_user_id"),
"organization_invites",
["created_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_invites_invited_email"),
"organization_invites",
["invited_email"],
unique=False,
)
op.create_index(
op.f("ix_organization_invites_organization_id"),
"organization_invites",
["organization_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_invites_role"), "organization_invites", ["role"], unique=False
)
op.create_index(
op.f("ix_organization_invites_token"), "organization_invites", ["token"], unique=False
)
op.create_table(
"organization_members",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("all_boards_read", sa.Boolean(), nullable=False),
sa.Column("all_boards_write", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("organization_id", "user_id", name="uq_organization_members_org_user"),
)
op.create_index(
op.f("ix_organization_members_organization_id"),
"organization_members",
["organization_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_members_role"), "organization_members", ["role"], unique=False
)
op.create_index(
op.f("ix_organization_members_user_id"), "organization_members", ["user_id"], unique=False
)
op.create_table(
"agents",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("openclaw_session_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("agent_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("heartbeat_config", sa.JSON(), nullable=True),
sa.Column("identity_profile", sa.JSON(), nullable=True),
sa.Column("identity_template", sa.Text(), nullable=True),
sa.Column("soul_template", sa.Text(), nullable=True),
sa.Column("provision_requested_at", sa.DateTime(), nullable=True),
sa.Column(
"provision_confirm_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column("provision_action", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("delete_requested_at", sa.DateTime(), nullable=True),
sa.Column("delete_confirm_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_board_lead", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_agents_agent_token_hash"), "agents", ["agent_token_hash"], unique=False
)
op.create_index(op.f("ix_agents_board_id"), "agents", ["board_id"], unique=False)
op.create_index(
op.f("ix_agents_delete_confirm_token_hash"),
"agents",
["delete_confirm_token_hash"],
unique=False,
)
op.create_index(op.f("ix_agents_is_board_lead"), "agents", ["is_board_lead"], unique=False)
op.create_index(op.f("ix_agents_name"), "agents", ["name"], unique=False)
op.create_index(
op.f("ix_agents_openclaw_session_id"), "agents", ["openclaw_session_id"], unique=False
)
op.create_index(
op.f("ix_agents_provision_action"), "agents", ["provision_action"], unique=False
)
op.create_index(
op.f("ix_agents_provision_confirm_token_hash"),
"agents",
["provision_confirm_token_hash"],
unique=False,
)
op.create_index(op.f("ix_agents_status"), "agents", ["status"], unique=False)
op.create_table(
"board_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column("is_chat", sa.Boolean(), nullable=False),
sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_board_memory_board_id"), "board_memory", ["board_id"], unique=False)
op.create_index(op.f("ix_board_memory_is_chat"), "board_memory", ["is_chat"], unique=False)
op.create_table(
"board_onboarding_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("messages", sa.JSON(), nullable=True),
sa.Column("draft_goal", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_board_onboarding_sessions_board_id"),
"board_onboarding_sessions",
["board_id"],
unique=False,
)
op.create_index(
op.f("ix_board_onboarding_sessions_status"),
"board_onboarding_sessions",
["status"],
unique=False,
)
op.create_table(
"organization_board_access",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_member_id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False),
sa.Column("can_write", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["organization_member_id"],
["organization_members.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_member_id", "board_id", name="uq_org_board_access_member_board"
),
)
op.create_index(
op.f("ix_organization_board_access_board_id"),
"organization_board_access",
["board_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_board_access_organization_member_id"),
"organization_board_access",
["organization_member_id"],
unique=False,
)
op.create_table(
"organization_invite_board_access",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_invite_id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False),
sa.Column("can_write", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["organization_invite_id"],
["organization_invites.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_invite_id", "board_id", name="uq_org_invite_board_access_invite_board"
),
)
op.create_index(
op.f("ix_organization_invite_board_access_board_id"),
"organization_invite_board_access",
["board_id"],
unique=False,
)
op.create_index(
op.f("ix_organization_invite_board_access_organization_invite_id"),
"organization_invite_board_access",
["organization_invite_id"],
unique=False,
)
op.create_table(
"tasks",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=True),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("due_at", sa.DateTime(), nullable=True),
sa.Column("in_progress_at", sa.DateTime(), nullable=True),
sa.Column("created_by_user_id", sa.Uuid(), nullable=True),
sa.Column("assigned_agent_id", sa.Uuid(), nullable=True),
sa.Column("auto_created", sa.Boolean(), nullable=False),
sa.Column("auto_reason", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["assigned_agent_id"],
["agents.id"],
),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_tasks_assigned_agent_id"), "tasks", ["assigned_agent_id"], unique=False
)
op.create_index(op.f("ix_tasks_board_id"), "tasks", ["board_id"], unique=False)
op.create_index(
op.f("ix_tasks_created_by_user_id"), "tasks", ["created_by_user_id"], unique=False
)
op.create_index(op.f("ix_tasks_priority"), "tasks", ["priority"], unique=False)
op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False)
op.create_table(
"activity_events",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("message", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("agent_id", sa.Uuid(), nullable=True),
sa.Column("task_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["agent_id"],
["agents.id"],
),
sa.ForeignKeyConstraint(
["task_id"],
["tasks.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_activity_events_agent_id"), "activity_events", ["agent_id"], unique=False
)
op.create_index(
op.f("ix_activity_events_event_type"), "activity_events", ["event_type"], unique=False
)
op.create_index(
op.f("ix_activity_events_task_id"), "activity_events", ["task_id"], unique=False
)
op.create_table(
"approvals",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=True),
sa.Column("agent_id", sa.Uuid(), nullable=True),
sa.Column("action_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("confidence", sa.Integer(), nullable=False),
sa.Column("rubric_scores", sa.JSON(), nullable=True),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["agent_id"],
["agents.id"],
),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["task_id"],
["tasks.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_approvals_agent_id"), "approvals", ["agent_id"], unique=False)
op.create_index(op.f("ix_approvals_board_id"), "approvals", ["board_id"], unique=False)
op.create_index(op.f("ix_approvals_status"), "approvals", ["status"], unique=False)
op.create_index(op.f("ix_approvals_task_id"), "approvals", ["task_id"], unique=False)
op.create_table(
"task_dependencies",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("depends_on_task_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.CheckConstraint("task_id <> depends_on_task_id", name="ck_task_dependencies_no_self"),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["depends_on_task_id"],
["tasks.id"],
),
sa.ForeignKeyConstraint(
["task_id"],
["tasks.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"task_id", "depends_on_task_id", name="uq_task_dependencies_task_id_depends_on_task_id"
),
)
op.create_index(
op.f("ix_task_dependencies_board_id"), "task_dependencies", ["board_id"], unique=False
)
op.create_index(
op.f("ix_task_dependencies_depends_on_task_id"),
"task_dependencies",
["depends_on_task_id"],
unique=False,
)
op.create_index(
op.f("ix_task_dependencies_task_id"), "task_dependencies", ["task_id"], unique=False
)
op.create_table(
"task_fingerprints",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("fingerprint_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["board_id"],
["boards.id"],
),
sa.ForeignKeyConstraint(
["task_id"],
["tasks.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_task_fingerprints_board_id"), "task_fingerprints", ["board_id"], unique=False
)
op.create_index(
op.f("ix_task_fingerprints_fingerprint_hash"),
"task_fingerprints",
["fingerprint_hash"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_task_fingerprints_fingerprint_hash"), table_name="task_fingerprints")
op.drop_index(op.f("ix_task_fingerprints_board_id"), table_name="task_fingerprints")
op.drop_table("task_fingerprints")
op.drop_index(op.f("ix_task_dependencies_task_id"), table_name="task_dependencies")
op.drop_index(op.f("ix_task_dependencies_depends_on_task_id"), table_name="task_dependencies")
op.drop_index(op.f("ix_task_dependencies_board_id"), table_name="task_dependencies")
op.drop_table("task_dependencies")
op.drop_index(op.f("ix_approvals_task_id"), table_name="approvals")
op.drop_index(op.f("ix_approvals_status"), table_name="approvals")
op.drop_index(op.f("ix_approvals_board_id"), table_name="approvals")
op.drop_index(op.f("ix_approvals_agent_id"), table_name="approvals")
op.drop_table("approvals")
op.drop_index(op.f("ix_activity_events_task_id"), table_name="activity_events")
op.drop_index(op.f("ix_activity_events_event_type"), table_name="activity_events")
op.drop_index(op.f("ix_activity_events_agent_id"), table_name="activity_events")
op.drop_table("activity_events")
op.drop_index(op.f("ix_tasks_status"), table_name="tasks")
op.drop_index(op.f("ix_tasks_priority"), table_name="tasks")
op.drop_index(op.f("ix_tasks_created_by_user_id"), table_name="tasks")
op.drop_index(op.f("ix_tasks_board_id"), table_name="tasks")
op.drop_index(op.f("ix_tasks_assigned_agent_id"), table_name="tasks")
op.drop_table("tasks")
op.drop_index(
op.f("ix_organization_invite_board_access_organization_invite_id"),
table_name="organization_invite_board_access",
)
op.drop_index(
op.f("ix_organization_invite_board_access_board_id"),
table_name="organization_invite_board_access",
)
op.drop_table("organization_invite_board_access")
op.drop_index(
op.f("ix_organization_board_access_organization_member_id"),
table_name="organization_board_access",
)
op.drop_index(
op.f("ix_organization_board_access_board_id"), table_name="organization_board_access"
)
op.drop_table("organization_board_access")
op.drop_index(
op.f("ix_board_onboarding_sessions_status"), table_name="board_onboarding_sessions"
)
op.drop_index(
op.f("ix_board_onboarding_sessions_board_id"), table_name="board_onboarding_sessions"
)
op.drop_table("board_onboarding_sessions")
op.drop_index(op.f("ix_board_memory_is_chat"), table_name="board_memory")
op.drop_index(op.f("ix_board_memory_board_id"), table_name="board_memory")
op.drop_table("board_memory")
op.drop_index(op.f("ix_agents_status"), table_name="agents")
op.drop_index(op.f("ix_agents_provision_confirm_token_hash"), table_name="agents")
op.drop_index(op.f("ix_agents_provision_action"), table_name="agents")
op.drop_index(op.f("ix_agents_openclaw_session_id"), table_name="agents")
op.drop_index(op.f("ix_agents_name"), table_name="agents")
op.drop_index(op.f("ix_agents_is_board_lead"), table_name="agents")
op.drop_index(op.f("ix_agents_delete_confirm_token_hash"), table_name="agents")
op.drop_index(op.f("ix_agents_board_id"), table_name="agents")
op.drop_index(op.f("ix_agents_agent_token_hash"), table_name="agents")
op.drop_table("agents")
op.drop_index(op.f("ix_organization_members_user_id"), table_name="organization_members")
op.drop_index(op.f("ix_organization_members_role"), table_name="organization_members")
op.drop_index(
op.f("ix_organization_members_organization_id"), table_name="organization_members"
)
op.drop_table("organization_members")
op.drop_index(op.f("ix_organization_invites_token"), table_name="organization_invites")
op.drop_index(op.f("ix_organization_invites_role"), table_name="organization_invites")
op.drop_index(
op.f("ix_organization_invites_organization_id"), table_name="organization_invites"
)
op.drop_index(op.f("ix_organization_invites_invited_email"), table_name="organization_invites")
op.drop_index(
op.f("ix_organization_invites_created_by_user_id"), table_name="organization_invites"
)
op.drop_index(
op.f("ix_organization_invites_accepted_by_user_id"), table_name="organization_invites"
)
op.drop_table("organization_invites")
op.drop_index(op.f("ix_boards_slug"), table_name="boards")
op.drop_index(op.f("ix_boards_organization_id"), table_name="boards")
op.drop_index(op.f("ix_boards_gateway_id"), table_name="boards")
op.drop_index(op.f("ix_boards_board_type"), table_name="boards")
op.drop_index(op.f("ix_boards_board_group_id"), table_name="boards")
op.drop_table("boards")
op.drop_index(op.f("ix_board_group_memory_is_chat"), table_name="board_group_memory")
op.drop_index(op.f("ix_board_group_memory_board_group_id"), table_name="board_group_memory")
op.drop_table("board_group_memory")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_index(op.f("ix_users_clerk_user_id"), table_name="users")
op.drop_index(op.f("ix_users_active_organization_id"), table_name="users")
op.drop_table("users")
op.drop_index(op.f("ix_gateways_organization_id"), table_name="gateways")
op.drop_table("gateways")
op.drop_index(op.f("ix_board_groups_slug"), table_name="board_groups")
op.drop_index(op.f("ix_board_groups_organization_id"), table_name="board_groups")
op.drop_table("board_groups")
op.drop_index(op.f("ix_organizations_name"), table_name="organizations")
op.drop_table("organizations")
# ### end Alembic commands ###

View File

@@ -1,12 +1,12 @@
[tool.black]
line-length = 100
target-version = ["py312"]
extend-exclude = '(\.venv|alembic/versions)'
extend-exclude = '(\.venv|migrations/versions)'
[tool.isort]
profile = "black"
line_length = 100
skip = [".venv", "alembic/versions"]
skip = [".venv", "migrations/versions"]
[project]
name = "openclaw-agency-backend"
version = "0.1.0"
@@ -59,7 +59,7 @@ asyncio_default_fixture_loop_scope = "function"
branch = true
source = ["app"]
# Migrations are generated artifacts; testing them doesn't add coverage signal.
omit = ["alembic/versions/*"]
omit = ["migrations/versions/*"]
[tool.coverage.report]
show_missing = true

View File

@@ -15,6 +15,9 @@ class _FakeSession:
executed: list[Any] = field(default_factory=list)
committed: int = 0
async def exec(self, statement: Any) -> None:
self.executed.append(statement)
async def execute(self, statement: Any) -> None:
self.executed.append(statement)

View File

@@ -17,7 +17,11 @@ class _FakeSession:
deleted: list[Any] = field(default_factory=list)
committed: int = 0
async def exec(self, _statement: Any) -> Any:
async def exec(self, statement: Any) -> Any:
is_dml = statement.__class__.__name__ in {"Delete", "Update", "Insert"}
if is_dml:
self.executed.append(statement)
return None
if not self.exec_results:
raise AssertionError("No more exec_results left for session.exec")
return self.exec_results.pop(0)

View File

@@ -16,6 +16,9 @@ class _FakeSession:
executed: list[Any] = field(default_factory=list)
committed: int = 0
async def exec(self, statement: Any) -> None:
self.executed.append(statement)
async def execute(self, statement: Any) -> None:
self.executed.append(statement)

View File

@@ -36,6 +36,10 @@ class _FakeSession:
committed: int = 0
async def exec(self, _statement: Any) -> Any:
is_dml = _statement.__class__.__name__ in {"Delete", "Update", "Insert"}
if is_dml:
self.executed.append(_statement)
return None
if not self.exec_results:
raise AssertionError("No more exec_results left for session.exec")
return self.exec_results.pop(0)

View File

@@ -51,6 +51,10 @@ class _FakeSession:
refreshed: list[Any] = field(default_factory=list)
async def exec(self, _statement: Any) -> Any:
is_dml = _statement.__class__.__name__ in {"Delete", "Update", "Insert"}
if is_dml:
self.executed.append(_statement)
return None
if not self.exec_results:
raise AssertionError("No more exec_results left for session.exec")
return self.exec_results.pop(0)

View File

@@ -68,6 +68,10 @@ class _FakeSession:
added: list[object] = field(default_factory=list)
async def exec(self, _query):
is_dml = _query.__class__.__name__ in {"Delete", "Update", "Insert"}
if is_dml:
self.executed.append(_query)
return None
if not self.exec_results:
raise AssertionError("No more exec_results left for session.exec")
return self.exec_results.pop(0)

View File

@@ -49,7 +49,7 @@ flowchart LR
### Data stores
- **Postgres**: persistence for boards/tasks/agents/approvals/etc.
- Models: `backend/app/models/*`
- Migrations: `backend/alembic/*`
- Migrations: `backend/migrations/*`
- **Redis**: used for background primitives.
- RQ helper: `backend/app/workers/queue.py`