Files
roboco/roboco/db/tables.py
T
Renn F 99eac69aab fix(audit): migrate audit_log.details to JSONB so .astext works
Task 13's has_recent_tracing_gap query used .astext on a generic JSON
column, which raises AttributeError at runtime. The choreographer's
exception handler swallowed it, leaving the strike-count reset
permanently inert in production. Migrate the column to JSONB (which
supports .astext + GIN indexing for future audit queries), update the
ORM, and add a real-DB integration test that would have caught this.
2026-05-03 08:19:50 +02:00

1707 lines
59 KiB
Python

"""
SQLAlchemy Table Definitions
ORM mappings for all RoboCo data models.
"""
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID as PyUUID
from uuid import uuid4
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
Interval,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from roboco.db.base import Base
from roboco.models.a2a import A2AConversationStatus, A2AMessageKind
from roboco.models.base import (
AgentRole,
AgentStatus,
AssignmentScope,
BlockerResolverType,
ChannelType,
Complexity,
HandoffStatus,
JournalEntryType,
MessageType,
ModelProvider,
NotificationPriority,
NotificationType,
SessionStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.session import SessionScope
from roboco.models.work_session import WorkSessionStatus
def _str_enum(enum_cls: type) -> Enum:
"""SQLAlchemy Enum that serializes by `.value` (lowercase) for StrEnum types.
Matches the lowercase values declared in alembic/versions/001_initial_schema.py.
Without values_callable, SQLAlchemy uses `.name` (uppercase) which does not
match the alembic-declared enum members.
"""
return Enum(enum_cls, values_callable=lambda obj: [m.value for m in obj])
# =============================================================================
# AGENT TABLE
# =============================================================================
class AgentTable(Base):
"""SQLAlchemy table for agents."""
__tablename__ = "agents"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
# Role & Team
role: Mapped[AgentRole] = mapped_column(_str_enum(AgentRole), nullable=False)
team: Mapped[Team | None] = mapped_column(_str_enum(Team), nullable=True)
# Status
status: Mapped[AgentStatus] = mapped_column(
_str_enum(AgentStatus), nullable=False, default=AgentStatus.OFFLINE
)
current_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
# Configuration (stored as JSON)
model_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Permissions (stored as JSON)
permissions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Metrics (stored as JSON)
metrics: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Journal
journal_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
# Description
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="joined" for single optional relationship
current_task: Mapped["TaskTable | None"] = relationship(
"TaskTable", foreign_keys=[current_task_id], lazy="joined"
)
# =============================================================================
# TASK TABLE
# =============================================================================
class TaskTable(Base):
"""SQLAlchemy table for tasks."""
__tablename__ = "tasks"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
acceptance_criteria: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False
)
# Status
status: Mapped[TaskStatus] = mapped_column(
_str_enum(TaskStatus), nullable=False, default=TaskStatus.PENDING, index=True
)
# Only meaningful when status == BLOCKED. Tells the dispatcher whether
# another agent can be respawned to resolve the block (`agent`) or
# whether the block is waiting on a human and spawning is wasted work
# (`human`). NULL for never-blocked tasks and for pre-existing rows.
blocker_resolver_type: Mapped[BlockerResolverType | None] = mapped_column(
_str_enum(BlockerResolverType), nullable=True
)
# Agent who raised the current block/escalation. Escalate reassigns the
# task to the resolver (PM) so they can work the fix, which loses the
# original dev's identity. We stash it here so `unblock` can flip
# assignment back and the orchestrator respawns the right agent. NULL
# = never blocked or pre-migration row.
blocker_raised_by: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
# Task Type & Git Configuration (all tasks follow git workflow)
task_type: Mapped[TaskType] = mapped_column(
_str_enum(TaskType), nullable=False, default=TaskType.CODE
)
nature: Mapped[TaskNature] = mapped_column(
_str_enum(TaskNature), nullable=False, default=TaskNature.TECHNICAL
)
# Project & Branch (branch auto-created on claim)
project_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
branch_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
work_session_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("work_sessions.id", ondelete="SET NULL"),
nullable=True,
)
# PR Tracking
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
docs_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pr_created: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PM Approval Tracking (for AWAITING_PM_REVIEW phase)
pm_approvals: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Ownership
created_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
)
assigned_to: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
team: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False, index=True)
# Relationships
parent_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
dependency_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
blocker_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Ordering (for sibling tasks under the same parent)
sequence: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, index=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
claimed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
claimed_by: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
target_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Planning (stored as JSON)
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
estimated_complexity: Mapped[Complexity] = mapped_column(
_str_enum(Complexity), nullable=False, default=Complexity.MEDIUM
)
# Execution (stored as JSON)
execution_log: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
checkpoints: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
progress_updates: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Artifacts (stored as JSON)
commits: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
documents: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
outputs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Documentation
dev_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
qa_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
auditor_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Review Status
self_verified: Mapped[bool] = mapped_column(Boolean, default=False)
qa_verified: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# Quick Context
quick_context: Mapped[str | None] = mapped_column(Text, nullable=True)
# Proactive Knowledge Context (injected when task is claimed)
proactive_context: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
# Gateway coordination (added in migration 006_gateway_columns).
# active_claimant_id + last_heartbeat_at implement the single-claimant
# invariant: only one agent holds a task at a time and they prove
# liveness via periodic heartbeats. Stale claims are cleaned up by the
# trigger filter.
active_claimant_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# pre_block_* snapshot the task's state at the moment it was blocked so
# `unblock(restore=True)` can return it to its prior status/assignee/
# metadata instead of dumping the agent into pending.
pre_block_state: Mapped[str | None] = mapped_column(String(40), nullable=True)
pre_block_assignee: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
pre_block_metadata: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
# acceptance_criteria_status: per-criterion records of the form
# {"criterion": "<text>", "referencing_artifact_id": "<commit-sha|note-id>"}.
# The tracing gate refuses transitions until every criterion has one.
acceptance_criteria_status: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
# qa_evidence_inspected: set true by claim_review when the QA agent
# inspects the inline diff/commits. The pass-review gate refuses the
# pass transition unless this is true.
qa_evidence_inspected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# Relationships
creator: Mapped["AgentTable"] = relationship(
"AgentTable", foreign_keys=[created_by], lazy="joined"
)
assignee: Mapped["AgentTable | None"] = relationship(
"AgentTable", foreign_keys=[assigned_to], lazy="joined"
)
parent_task: Mapped["TaskTable | None"] = relationship(
"TaskTable", remote_side=[id], lazy="select"
)
project: Mapped["ProjectTable"] = relationship(
"ProjectTable", foreign_keys=[project_id], lazy="joined"
)
# Session links (many-to-many via SessionTaskTable).
# passive_deletes=True tells SA to trust the DB's ON DELETE CASCADE and
# NOT emit `UPDATE session_tasks SET task_id=NULL` before the delete —
# the task_id column is NOT NULL, so that pre-null attempt hits an
# IntegrityError and DELETE /tasks/{id} fails with NotNullViolation.
session_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable",
back_populates="task",
lazy="select",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
# Composite indexes for common queries
Index("ix_tasks_team_status", "team", "status"),
Index("ix_tasks_assigned_status", "assigned_to", "status"),
Index("ix_tasks_created_by_status", "created_by", "status"),
Index("ix_tasks_project_status", "project_id", "status"),
)
# =============================================================================
# PROJECT TABLE
# =============================================================================
class ProjectTable(Base):
"""SQLAlchemy table for projects (git repositories)."""
__tablename__ = "projects"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
# Git Configuration
git_url: Mapped[str] = mapped_column(String(500), nullable=False)
default_branch: Mapped[str] = mapped_column(
String(100), nullable=False, default="main"
)
protected_branches: Mapped[list[str]] = mapped_column(
ARRAY(String), default=lambda: ["main", "master"]
)
git_token_encrypted: Mapped[str | None] = mapped_column(
Text, nullable=True
) # Fernet-encrypted GitHub PAT
# CI/CD Commands (optional)
test_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
lint_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
format_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
typecheck_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
build_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
allowed_agents: Mapped[list[PyUUID] | None] = mapped_column(
ARRAY(UUID(as_uuid=True)), nullable=True
)
# Runtime State
workspace_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
last_synced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
head_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Metadata
created_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
creator: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
__table_args__ = (
Index("ix_projects_cell", "assigned_cell"),
Index("ix_projects_active", "is_active"),
)
# =============================================================================
# WORK SESSION TABLE
# =============================================================================
class WorkSessionTable(Base):
"""SQLAlchemy table for work sessions (git working sessions)."""
__tablename__ = "work_sessions"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
project_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
task_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Branch Management
branch_name: Mapped[str] = mapped_column(String(500), nullable=False)
base_branch: Mapped[str] = mapped_column(String(500), nullable=False)
target_branch: Mapped[str] = mapped_column(String(500), nullable=False)
# Lifecycle
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
ended_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status: Mapped[WorkSessionStatus] = mapped_column(
_str_enum(WorkSessionStatus),
nullable=False,
default=WorkSessionStatus.ACTIVE,
index=True,
)
# Audit Trail
commits: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
files_modified: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# PR Tracking
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
pr_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
pr_created_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
pr_merged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Merge tracking
merged_by: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
project: Mapped["ProjectTable"] = relationship("ProjectTable", lazy="joined")
agent: Mapped["AgentTable | None"] = relationship(
"AgentTable", foreign_keys=[agent_id], lazy="joined"
)
merger: Mapped["AgentTable | None"] = relationship(
"AgentTable", foreign_keys=[merged_by], lazy="select"
)
__table_args__ = (
Index("ix_work_sessions_project_status", "project_id", "status"),
Index("ix_work_sessions_task", "task_id"),
Index("ix_work_sessions_agent_status", "agent_id", "status"),
)
# =============================================================================
# CHANNEL TABLE
# =============================================================================
class ChannelTable(Base):
"""SQLAlchemy table for channels."""
__tablename__ = "channels"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
type: Mapped[ChannelType] = mapped_column(_str_enum(ChannelType), nullable=False)
# Description
description: Mapped[str | None] = mapped_column(Text, nullable=True)
topic: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control
members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
writers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
silent_observers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
allow_threads: Mapped[bool] = mapped_column(Boolean, default=True)
allow_reactions: Mapped[bool] = mapped_column(Boolean, default=True)
message_retention_days: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=90
)
max_message_length: Mapped[int] = mapped_column(Integer, default=10000)
# Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0)
group_count: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for collections to avoid N+1
groups: Mapped[list["GroupTable"]] = relationship(
"GroupTable", back_populates="channel", lazy="select"
)
# =============================================================================
# GROUP TABLE
# =============================================================================
class GroupTable(Base):
"""SQLAlchemy table for groups."""
__tablename__ = "groups"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
channel_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("channels.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Access Control
allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
hierarchy_level: Mapped[int] = mapped_column(Integer, default=4)
members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Current Session
active_session_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
# Session Configuration (stored as JSON)
default_session_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Statistics
total_sessions: Mapped[int] = mapped_column(Integer, default=0)
total_messages: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for collections to avoid N+1
channel: Mapped["ChannelTable"] = relationship(
"ChannelTable", back_populates="groups"
)
sessions: Mapped[list["SessionTable"]] = relationship(
"SessionTable", back_populates="group", lazy="select"
)
# =============================================================================
# SESSION TABLE
# =============================================================================
class SessionTable(Base):
"""SQLAlchemy table for sessions."""
__tablename__ = "sessions"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
group_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Boundaries
max_time_window: Mapped[timedelta | None] = mapped_column(
Interval, nullable=True, default=timedelta(minutes=30)
)
max_message_count: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=100
)
max_content_length: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=50000
)
# Timeout
timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
# State
status: Mapped[SessionStatus] = mapped_column(
_str_enum(SessionStatus),
nullable=False,
default=SessionStatus.ACTIVE,
index=True,
)
# Scope (for smart context loading)
scope: Mapped[SessionScope] = mapped_column(
_str_enum(SessionScope), nullable=False, default=SessionScope.TASK, index=True
)
# Timestamps
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
last_activity_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0)
total_content_length: Mapped[int] = mapped_column(Integer, default=0)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships - CRITICAL: use lazy="select" for messages (sessions can have 100+)
group: Mapped["GroupTable"] = relationship("GroupTable", back_populates="sessions")
messages: Mapped[list["MessageTable"]] = relationship(
"MessageTable", back_populates="session", lazy="select"
)
# Task links (many-to-many via SessionTaskTable).
# passive_deletes mirrors the TaskTable side — the DB FK is CASCADE, so
# deleting a session should cascade-delete session_tasks rows. Without
# passive_deletes, SA tries to NULL task_id first, violating NOT NULL.
task_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable",
back_populates="session",
lazy="select",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
# Composite indexes for common queries
Index("ix_sessions_group_status", "group_id", "status"),
Index("ix_sessions_status_started", "status", "started_at"),
)
# =============================================================================
# SESSION-TASK JUNCTION TABLE
# =============================================================================
class SessionTaskTable(Base):
"""
Junction table for many-to-many Session ↔ Task relationship.
Enables PMs to create work sessions as discussion contexts for tasks.
A task can have multiple sessions (planning, review, retrospective).
A session can discuss multiple related tasks.
"""
__tablename__ = "session_tasks"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Foreign Keys (indexes defined in __table_args__)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
)
task_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
)
# Relationship Metadata
is_primary: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
) # Primary discussion session for this task
relationship_type: Mapped[str] = mapped_column(
String(50), default="discussion", nullable=False
) # "discussion", "planning", "review", "retrospective"
# Audit
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
added_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True, # Allow NULL if PM is deleted
)
# Relationships
session: Mapped["SessionTable"] = relationship(
"SessionTable", back_populates="task_links", lazy="joined"
)
task: Mapped["TaskTable"] = relationship(
"TaskTable", back_populates="session_links", lazy="joined"
)
added_by_agent: Mapped["AgentTable | None"] = relationship(
"AgentTable", lazy="select"
)
__table_args__ = (
# Each session-task pair is unique
UniqueConstraint("session_id", "task_id", name="uq_session_task"),
# Partial unique index: only one primary session per task
Index(
"ix_session_tasks_primary_per_task",
"task_id",
unique=True,
postgresql_where=(is_primary.is_(True)),
),
# Fast lookups
Index("ix_session_tasks_task_id", "task_id"),
Index("ix_session_tasks_session_id", "session_id"),
Index("ix_session_tasks_type", "relationship_type"),
)
# =============================================================================
# MESSAGE TABLE
# =============================================================================
class MessageTable(Base):
"""SQLAlchemy table for messages."""
__tablename__ = "messages"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Source & Context
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True
)
channel_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("channels.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
group_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Content
type: Mapped[MessageType] = mapped_column(
_str_enum(MessageType), nullable=False, index=True
)
content: Mapped[str] = mapped_column(Text, nullable=False)
content_length: Mapped[int] = mapped_column(Integer, nullable=False)
# Threading
is_reply: Mapped[bool] = mapped_column(Boolean, default=False)
reply_to: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
)
# Mentions
mentions: Mapped[list[UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Task Context
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
commit_ref: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Metadata
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
# Extraction metadata
confidence: Mapped[float] = mapped_column(Float, default=1.0)
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
# Edit tracking (stored as JSON)
edited_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships - use lazy="joined" for agent to avoid N+1 on message lists
agent: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
session: Mapped["SessionTable"] = relationship(
"SessionTable", back_populates="messages"
)
parent_message: Mapped["MessageTable | None"] = relationship(
"MessageTable", remote_side=[id], lazy="select"
)
__table_args__ = (
# Composite indexes for efficient queries
Index("ix_messages_channel_timestamp", "channel_id", "timestamp"),
Index("ix_messages_agent_timestamp", "agent_id", "timestamp"),
Index("ix_messages_session_timestamp", "session_id", "timestamp"),
)
# =============================================================================
# NOTIFICATION TABLE
# =============================================================================
class NotificationTable(Base):
"""SQLAlchemy table for notifications."""
__tablename__ = "notifications"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
type: Mapped[NotificationType] = mapped_column(
_str_enum(NotificationType), nullable=False, index=True
)
priority: Mapped[NotificationPriority] = mapped_column(
_str_enum(NotificationPriority),
nullable=False,
default=NotificationPriority.NORMAL,
)
# Routing
from_agent: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
)
to_agents: Mapped[list[UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), nullable=False
)
# Content
subject: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
# Acknowledgment
requires_ack: Mapped[bool] = mapped_column(Boolean, default=True)
acked_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Context
related_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
related_message_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Timing
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Read tracking
read_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Delivery tracking
delivered_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships
sender: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
related_task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
__table_args__ = (
# Composite indexes for notification queries
Index("ix_notifications_from_agent_timestamp", "from_agent", "timestamp"),
Index("ix_notifications_type_priority", "type", "priority"),
Index("ix_notifications_timestamp_priority", "timestamp", "priority"),
)
# =============================================================================
# JOURNAL TABLE
# =============================================================================
class JournalTable(Base):
"""SQLAlchemy table for journals."""
__tablename__ = "journals"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Metadata
total_entries: Mapped[int] = mapped_column(Integer, default=0)
last_entry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Summary
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Growth metrics (stored as JSON)
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for entries collection to avoid N+1
agent: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
entries: Mapped[list["JournalEntryTable"]] = relationship(
"JournalEntryTable", back_populates="journal", lazy="select"
)
class JournalEntryTable(Base):
"""SQLAlchemy table for journal entries."""
__tablename__ = "journal_entries"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
journal_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("journals.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Content
type: Mapped[JournalEntryType] = mapped_column(
_str_enum(JournalEntryType), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# Context
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
session_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True,
)
# Metadata
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Sentiment
sentiment: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Visibility
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
journal: Mapped["JournalTable"] = relationship(
"JournalTable", back_populates="entries"
)
__table_args__ = (
# Composite indexes for journal entry queries
Index("ix_journal_entries_journal_timestamp", "journal_id", "timestamp"),
Index("ix_journal_entries_journal_type", "journal_id", "type"),
Index("ix_journal_entries_task_id", "task_id"),
)
# =============================================================================
# HANDOFF TABLE
# =============================================================================
class HandoffTable(Base):
"""
SQLAlchemy table for structured documentation handoffs.
STATUS: RESERVED FOR FUTURE USE
===============================
This table exists in the schema but has no service layer or API yet.
Current Implementation:
Handoffs use the simpler `dev_notes` + `handoff_summary` parameters
in `roboco_task_submit_qa()`, stored directly on the task.
Future Enhancement:
This table enables richer, structured handoff documents with:
- Categorized changes (new functionality, breaking changes)
- Required vs optional documentation items
- Code samples, gotchas, key learnings
- Linked commits and file locations
To implement: create HandoffService + API routes + MCP tools.
"""
__tablename__ = "handoffs"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
task_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Summary
summary: Mapped[str] = mapped_column(Text, nullable=False)
# What Changed (stored as JSON arrays)
new_functionality: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
modified_behavior: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
breaking_changes: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Documentation Needed (stored as JSON)
required_docs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
optional_docs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Key Commits (stored as JSON)
commits: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
# Code Locations (stored as JSON)
new_files: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
modified_files: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
# Conversations (stored as JSON)
key_conversations: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Code Samples (stored as JSON)
code_samples: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Gotchas (stored as JSON)
gotchas: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
# Related Documentation
related_docs: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Changelog Entry
changelog_entry: Mapped[str | None] = mapped_column(Text, nullable=True)
# Key Learnings (stored as JSON)
key_learnings: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
key_decisions: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
# Questions
questions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Dev Notes Location
dev_notes_location: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Status
status: Mapped[HandoffStatus] = mapped_column(
_str_enum(HandoffStatus),
nullable=False,
default=HandoffStatus.PENDING,
index=True,
)
assigned_to: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
claimed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Documenter feedback
documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships
task: Mapped["TaskTable"] = relationship("TaskTable", lazy="joined")
assignee: Mapped["AgentTable | None"] = relationship("AgentTable", lazy="joined")
__table_args__ = (
UniqueConstraint("task_id", name="uq_handoffs_task_id"),
# Indexes for handoff queries
Index("ix_handoffs_assigned_status", "assigned_to", "status"),
Index("ix_handoffs_status_created", "status", "created_at"),
)
# =============================================================================
# INDEXED DOCUMENT TABLE (Knowledge Base tracking)
# =============================================================================
class IndexedDocumentTable(Base):
"""
Track documents indexed into the knowledge base.
This provides:
- Actual document count (vs chunk count from vector DB)
- Browsing capability for the KB UI
- Source tracking for re-indexing
"""
__tablename__ = "indexed_documents"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Index type (code, docs, conversations, journals, errors, standards, etc.)
index_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Source information
source: Mapped[str] = mapped_column(String(1000), nullable=False)
source_hash: Mapped[str] = mapped_column(
String(64), nullable=False
) # SHA256 for dedup
# Document title (extracted or filename)
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Content preview (first 500 chars for UI)
preview: Mapped[str | None] = mapped_column(Text, nullable=True)
# Chunk count for this document
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
# Extra data (extracted during indexing)
extra_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Timestamps
indexed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
__table_args__ = (
# Prevent duplicate indexing of same source
UniqueConstraint("index_type", "source_hash", name="uq_indexed_doc_source"),
Index("ix_indexed_docs_type_time", "index_type", "indexed_at"),
)
# =============================================================================
# A2A CONVERSATION TABLE
# =============================================================================
class A2AConversationTable(Base):
"""
Persistent A2A conversation between two agents.
Uses canonical ordering (agent_a < agent_b) for unique pair identification.
This enables persistent chat history across agent spawns.
"""
__tablename__ = "a2a_conversations"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# The pair (canonical order: lexically smaller first)
agent_a: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
agent_b: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Context
topic: Mapped[str | None] = mapped_column(String(255), nullable=True)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Status
status: Mapped[A2AConversationStatus] = mapped_column(
_str_enum(A2AConversationStatus),
nullable=False,
default=A2AConversationStatus.ACTIVE,
index=True,
)
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
# Stats
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
unread_by_a: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
unread_by_b: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
last_message_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
messages: Mapped[list["A2AMessageTable"]] = relationship(
"A2AMessageTable", back_populates="conversation", lazy="select"
)
__table_args__ = (
# Unique pair + topic combination
UniqueConstraint("agent_a", "agent_b", "topic", name="uq_a2a_pair_topic"),
# Ensure canonical ordering
# CheckConstraint("agent_a < agent_b", name="ck_a2a_agent_order"),
# Composite indexes
Index("ix_a2a_conv_pair", "agent_a", "agent_b"),
Index("ix_a2a_conv_status_updated", "status", "updated_at"),
)
# =============================================================================
# A2A MESSAGE TABLE
# =============================================================================
class A2AMessageTable(Base):
"""
Individual message in persistent A2A conversation.
Supports threading via response_to_id and read tracking.
"""
__tablename__ = "a2a_messages"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
conversation_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("a2a_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Sender (must be agent_a or agent_b from conversation)
from_agent: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Content
content: Mapped[str] = mapped_column(Text, nullable=False)
message_kind: Mapped[A2AMessageKind] = mapped_column(
_str_enum(A2AMessageKind),
nullable=False,
default=A2AMessageKind.MESSAGE,
)
# Threading
response_to_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("a2a_messages.id", ondelete="SET NULL"),
nullable=True,
)
requires_response: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# Read tracking
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
# Edit support
edited_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Relationships
conversation: Mapped["A2AConversationTable"] = relationship(
"A2AConversationTable", back_populates="messages"
)
response_to: Mapped["A2AMessageTable | None"] = relationship(
"A2AMessageTable", remote_side=[id], lazy="select"
)
__table_args__ = (
# Composite indexes for message queries
Index("ix_a2a_msg_conv_created", "conversation_id", "created_at"),
Index("ix_a2a_msg_from_created", "from_agent", "created_at"),
# Partial index for pending responses
Index(
"ix_a2a_msg_pending",
"conversation_id",
postgresql_where=(requires_response.is_(True)),
),
)
# =============================================================================
# ORCHESTRATOR WAITING RECORDS
# =============================================================================
class WaitingRecordTable(Base):
"""Persistent backing for orchestrator agents in WAITING_LONG state.
Previously kept only in `AgentOrchestrator._waiting_records` (in-memory),
so an orchestrator restart stranded every waiting agent permanently.
"""
__tablename__ = "waiting_records"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, index=True
)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
waiting_for: Mapped[str] = mapped_column(String(64), nullable=False)
waiting_since: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (Index("ix_waiting_records_waiting_for", "waiting_for"),)
# =============================================================================
# AUDIT LOG
# =============================================================================
class AuditLogTable(Base):
"""Durable audit events for compliance and the Auditor agent.
Replaces log-only audit. Dot-separated event_type (task.claimed,
session.closed, project.deleted, etc.) + JSON details.
"""
__tablename__ = "audit_log"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
agent_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
target_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
target_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
# JSONB (not generic JSON) so the comparator exposes `.astext` —
# `AuditService.has_recent_tracing_gap` filters
# `details->>'reason' == 'tracing_gap'`, which the generic JSON Comparator
# doesn't support (raises AttributeError). JSONB also supports GIN
# indexing for future audit queries.
details: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
__table_args__ = (
Index("ix_audit_log_agent_timestamp", "agent_id", "timestamp"),
Index("ix_audit_log_target", "target_type", "target_id"),
)
# =============================================================================
# PROVIDER ROUTING TABLES
# =============================================================================
class ProviderConfigTable(Base):
"""SQLAlchemy table for model-provider connections.
One row per "logical provider" — e.g., "Anthropic (default)" (a
pointer-only row with no secret, served by the mounted ~/.claude auth)
or "Ollama Cloud Kimi" (holds an encrypted API key and a base URL).
`ModelAssignmentTable` rows reference these via `provider_config_id`.
"""
__tablename__ = "provider_configs"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False, index=True
)
type: Mapped[ModelProvider] = mapped_column(
_str_enum(ModelProvider), nullable=False
)
# `base_url = NULL` → Anthropic-default path: no ANTHROPIC_BASE_URL
# injection, Claude Code inside the container uses its mounted
# ~/.claude credentials. Non-null routes to that endpoint instead.
base_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Fernet-encrypted auth token (Ollama API key for ollama_cloud rows;
# NULL for anthropic rows — their auth lives in the mounted directory).
auth_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
__table_args__ = (Index("ix_provider_configs_enabled", "enabled"),)
class ModelAssignmentTable(Base):
"""SQLAlchemy table for (scope, provider, model) routing rows.
Precedence at spawn time (implemented in `ModelRoutingService`):
AGENT_SLUG > ROLE > GLOBAL
with a legacy fallback to `ROLE_MODEL_MAP` when no row applies.
"""
__tablename__ = "model_assignments"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
scope: Mapped[AssignmentScope] = mapped_column(
_str_enum(AssignmentScope), nullable=False
)
# NULL when scope = 'global'; role name for 'role'; agent slug for 'agent_slug'.
scope_value: Mapped[str | None] = mapped_column(String(100), nullable=True)
provider_config_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("provider_configs.id", ondelete="RESTRICT"),
nullable=False,
)
# Raw Claude Code `--model` identifier (`claude-opus-4-7`,
# `kimi-k2.6:cloud`, etc.). The orchestrator's CLI-translation only
# fires for anthropic providers; non-anthropic values pass through.
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
provider: Mapped["ProviderConfigTable"] = relationship(
"ProviderConfigTable", lazy="joined"
)
__table_args__ = (
# NULLS NOT DISTINCT so the global row (scope_value=NULL) can't be
# duplicated. Requires PostgreSQL 15+ (roboco runs on pgvector 16).
Index(
"ux_model_assignments_scope_key",
"scope",
"scope_value",
unique=True,
postgresql_nulls_not_distinct=True,
),
Index("ix_model_assignments_provider", "provider_config_id"),
)
# =============================================================================
# GATEWAY TRIGGER TABLE
# =============================================================================
class GatewayTriggerTable(Base):
"""Records every dispatcher spawn-decision (spawn / queue / drop) for
observability and gateway-tuning. Written only when
``settings.gateway_enabled`` is True; the legacy spawn path never touches
this table.
"""
__tablename__ = "gateway_triggers"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
trigger_kind: Mapped[str] = mapped_column(String(40), nullable=False)
trigger_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
)
target_role: Mapped[str] = mapped_column(String(40), nullable=False)
decision: Mapped[str] = mapped_column(String(20), nullable=False)
decision_reason: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (
Index("ix_gateway_triggers_task_id", "task_id"),
Index("ix_gateway_triggers_created_at", "created_at"),
Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"),
)