from sqlalchemy import Column, String, Text, DateTime, Integer, Boolean, ForeignKey, JSON from sqlalchemy.orm import relationship from datetime import datetime, timezone from database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String, unique=True, nullable=False) email = Column(String, unique=True, nullable=True) password_hash = Column(String, nullable=False) display_name = Column(String, default="") role = Column(String, default="user") # admin or user llm_config = Column(JSON, default=dict) # user's own LLM provider config created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) instances = relationship("AgentInstance", back_populates="user") class AgentCatalog(Base): __tablename__ = "agent_catalog" id = Column(String, primary_key=True) name = Column(String, nullable=False) description = Column(Text, default="") category = Column(String, default="utility") # data, briefing, utility config_schema = Column(JSON, default=dict) default_config = Column(JSON, default=dict) supports_schedule = Column(Boolean, default=True) is_sub_agent = Column(Boolean, default=False) requires_llm = Column(Boolean, default=False) result_schema = Column(JSON, default=dict) # shape of the agent's structured result instances = relationship("AgentInstance", back_populates="catalog_entry") class AgentInstance(Base): __tablename__ = "agent_instances" id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) catalog_id = Column(String, ForeignKey("agent_catalog.id"), nullable=False) name = Column(String, nullable=False) config = Column(JSON, default=dict) schedule = Column(String, default="manual") status = Column(String, default="active") created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) user = relationship("User", back_populates="instances") catalog_entry = relationship("AgentCatalog", back_populates="instances") runs = relationship("Run", back_populates="instance", order_by="Run.started_at.desc()") class Run(Base): __tablename__ = "runs" id = Column(Integer, primary_key=True, autoincrement=True) instance_id = Column(Integer, ForeignKey("agent_instances.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) finished_at = Column(DateTime, nullable=True) status = Column(String, default="running") output = Column(Text, default="") # markdown rendering (for wiki) result = Column(JSON, nullable=True) # structured data for API consumers error = Column(Text, default="") metadata_ = Column("metadata", JSON, default=dict) triggered_by = Column(String, default="") # "user:eric", "api_client:synap", "cron" instance = relationship("AgentInstance", back_populates="runs") class Bridge(Base): __tablename__ = "bridges" id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) api_key = Column(String, nullable=False) # Auth token for bridge requests bridge_url = Column(String, default="") # http://ip:port hostname = Column(String, default="") # e.g. "Jungbauers-MBP" platform = Column(String, default="macos") # macos, ios (future) capabilities = Column(JSON, default=list) # ["notes", "reading-list"] status = Column(String, default="offline") # online, offline last_heartbeat = Column(DateTime, nullable=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) user = relationship("User") class RouteLog(Base): __tablename__ = "route_log" id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) request_text = Column(Text, nullable=False) recommended_agent = Column(String, default="") action = Column(String, default="") reasoning = Column(Text, default="") outcome = Column(String, default="pending") # pending, accepted, rejected, success, failed metadata_ = Column("metadata", JSON, default=dict) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) class LLMProvider(Base): __tablename__ = "llm_providers" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, nullable=False) provider_type = Column(String, default="anthropic") # anthropic, openai, litellm, ollama api_url = Column(String, default="") api_key = Column(String, default="") default_model = Column(String, default="") is_default = Column(Boolean, default=False) class APIClient(Base): """App-level API client. Each external app (Synap, WSIT, etc.) gets one. Scoped to specific agent instances — see APIClientScope.""" __tablename__ = "api_clients" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, nullable=False, unique=True) # "Synap", "WSIT" token_hash = Column(String, nullable=False, unique=True) # SHA-256 of the plaintext token token_prefix = Column(String, default="") # first 8 chars of token, shown in admin UI description = Column(Text, default="") created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) last_used_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True) scopes = relationship("APIClientScope", back_populates="client", cascade="all, delete-orphan") class APIClientScope(Base): """Join table: which instances can an API client trigger/read?""" __tablename__ = "api_client_scopes" id = Column(Integer, primary_key=True, autoincrement=True) api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False) instance_id = Column(Integer, ForeignKey("agent_instances.id", ondelete="CASCADE"), nullable=False) client = relationship("APIClient", back_populates="scopes") class APIClientCall(Base): """Audit log for every authenticated API call by an API client.""" __tablename__ = "api_client_calls" id = Column(Integer, primary_key=True, autoincrement=True) api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False) instance_id = Column(Integer, nullable=True) # may be null for non-instance endpoints endpoint = Column(String, default="") # e.g. "POST /api/instances/2/trigger" status_code = Column(Integer, default=0) called_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) class ServiceConfig(Base): """System-wide service credentials used by agents (Sonarr, Radarr, qBit, Plex, etc.). Keyed by service name (e.g. 'sonarr'). Admin manages these centrally, shared across users.""" __tablename__ = "service_configs" service_name = Column(String, primary_key=True) # 'sonarr', 'radarr', 'qbittorrent', 'plex', ... base_url = Column(String, default="") # e.g. http://192.168.1.203:8989 api_key = Column(String, default="") # API key or token (varies by service) username = Column(String, default="") # some services need both (qBit, Plex) password = Column(String, default="") extra = Column(JSON, default=dict) # any service-specific extras updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) class PirateConversation(Base): """A chat thread with The Pirate agent. Auto-resets 24h after last message unless user explicitly picks an old thread (client decides which conversation_id to use).""" __tablename__ = "pirate_conversations" id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) title = Column(String, default="") # auto-set from first user message created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) last_message_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) messages = relationship("PirateMessage", back_populates="conversation", cascade="all, delete-orphan", order_by="PirateMessage.created_at") class PirateMessage(Base): """One turn in a Pirate conversation. Role is 'user', 'assistant', or 'tool'. For 'assistant' turns with tool calls, tool_calls holds the LLM's requested calls. For 'tool' turns, tool_name + tool_result hold the execution output.""" __tablename__ = "pirate_messages" id = Column(Integer, primary_key=True, autoincrement=True) conversation_id = Column(Integer, ForeignKey("pirate_conversations.id", ondelete="CASCADE"), nullable=False) role = Column(String, nullable=False) # 'user', 'assistant', 'tool' content = Column(Text, default="") # text content (user or assistant message) tool_calls = Column(JSON, nullable=True) # list of {id, name, input} for assistant turns tool_call_id = Column(String, default="") # matches an assistant.tool_calls[*].id on 'tool' turns tool_name = Column(String, default="") # which tool was called on 'tool' turns tool_result = Column(JSON, nullable=True) # structured result of the tool call model = Column(String, default="") # LLM model that produced this assistant turn input_tokens = Column(Integer, default=0) output_tokens = Column(Integer, default=0) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) conversation = relationship("PirateConversation", back_populates="messages")