From a6da83f90b45b0c05961505c32bf4ef6caff8a48 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Tue, 23 Jun 2026 00:08:12 +0100 Subject: [PATCH 1/4] refactor(models): centralize router Pydantic models in models.py (INV-14, #654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 97 request/response BaseModel classes from 32 routers into src/backend/models.py (Architectural Invariant #14), plus the fan_out / loops / webhooks module-level constants their validators reference. Each class moved verbatim — validators, default_factory, and Config preserved. Behaviour preserved, proven two ways the move could silently break it: - Normalized app.openapi() snapshot diff: 330 paths / 198 schemas, byte-identical origin/dev vs branch (schema-level proof). - Validator-preservation unit tests for the 5 behaviour-bearing files (fan_out, loops, canary, audit_log, schedules) — the field_validator logic, default_factory dicts, and Config.from_attributes an OpenAPI diff can't see. One documented exception, allowlisted in the new static guard: canary.py::RunCycleRequest evaluates INVARIANTS (the canary library) in a Field(description=...) at class-definition time, and the canary library imports TaskExecutionStatus back from models — relocating it would force models.py to `from canary import ...`, inverting the dependency direction of a module meant to be a low-level leaf everything imports from. Flat models.py (not a package): a models/ dir would break tests/conftest.py's by-file model preload and the Dockerfile's by-file COPY *.py (the #1033 crash-loop class). Scope is router models only — db_models.py and adapters/base.py are intentional separate homes. New / changed: - tests/unit/test_models_centralized.py: static guard (no BaseModel under routers/ except the documented allowlist) + planted-violation tests - tests/unit/test_router_model_validators.py: validator / default_factory / Config preservation negatives - tests/unit/test_telegram_webhook_backfill.py: load the real models module in the standalone settings.py stub (was already red on dev) and stub the #1197 services.agent_service.capabilities import -> green - docs/memory/architecture.md: Invariant #14 scope note + re-measured router/service/agents counts Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/memory/architecture.md | 8 +- src/backend/models.py | 1164 +++++++++++++++++- src/backend/routers/agent_files.py | 20 +- src/backend/routers/agent_rename.py | 8 +- src/backend/routers/agent_ssh.py | 11 +- src/backend/routers/agents.py | 12 +- src/backend/routers/audit_log.py | 98 +- src/backend/routers/avatar.py | 7 +- src/backend/routers/canary.py | 98 +- src/backend/routers/event_subscriptions.py | 7 +- src/backend/routers/fan_out.py | 105 +- src/backend/routers/git.py | 47 +- src/backend/routers/image_generation.py | 11 +- src/backend/routers/internal.py | 73 +- src/backend/routers/logs.py | 20 +- src/backend/routers/loops.py | 78 +- src/backend/routers/messages.py | 55 +- src/backend/routers/notifications.py | 7 +- src/backend/routers/operator_queue.py | 28 +- src/backend/routers/paid.py | 8 +- src/backend/routers/public.py | 18 +- src/backend/routers/public_memory.py | 7 +- src/backend/routers/schedules.py | 163 +-- src/backend/routers/sessions.py | 28 +- src/backend/routers/settings.py | 70 +- src/backend/routers/setup.py | 27 +- src/backend/routers/sharing.py | 31 +- src/backend/routers/slack.py | 10 +- src/backend/routers/telegram.py | 58 +- src/backend/routers/users.py | 11 +- src/backend/routers/voice.py | 28 +- src/backend/routers/voip.py | 28 +- src/backend/routers/webhooks.py | 18 +- src/backend/routers/whatsapp.py | 28 +- tests/unit/test_models_centralized.py | 101 ++ tests/unit/test_router_model_validators.py | 154 +++ tests/unit/test_telegram_webhook_backfill.py | 38 +- 37 files changed, 1569 insertions(+), 1114 deletions(-) create mode 100644 tests/unit/test_models_centralized.py create mode 100644 tests/unit/test_router_model_validators.py diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 97ae86c27..583078af3 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -64,10 +64,10 @@ **OpenTelemetry tracing** (RELIABILITY-002): auto-instrumentation for FastAPI/httpx/Redis; `traceparent` propagated through inter-agent calls; OTLP/gRPC export to `trinity-otel-collector:4317`; `OTEL_ENABLED=1`, sampling via `OTEL_SAMPLE_RATE` (default 10%). -**Routers (`routers/`)** — 53 router modules: +**Routers (`routers/`)** — 63 router modules: *Core Agent:* -- `agents.py` - Core CRUD, start/stop, logs, stats, queue, activities, terminal (642 lines) +- `agents.py` - Core CRUD, start/stop, logs, stats, queue, activities, terminal (1054 lines) - `agent_config.py` - Per-agent settings: autonomy, read-only, resources, capabilities, capacity, timeout, api-key - `agent_files.py` - Files, info, playbooks, permissions, metrics, shared folders, file-sharing toggle + list/revoke (FILES-001) - `agent_data.py` - Runtime-data export/import (`data_paths`) over the durable home volume (#1169) @@ -139,7 +139,7 @@ - `system_agent.py` - System agent management - `sessions.py` - Session tab endpoints — see [Session Tab](#session-tab) -**Services (`services/`)** — 37 service modules: +**Services (`services/`)** — 66 service modules: *Core:* - `docker_service.py` - Docker container management (single point of Docker interaction, Invariant #11) @@ -800,7 +800,7 @@ These are structural patterns that must be preserved. Breaking them causes casca 13. **MCP Server = Third Surface in Sync** — The MCP server (`src/mcp-server/src/tools/*.ts`) is a TypeScript proxy over the backend API. When adding a backend endpoint for external access, the MCP tool module needs updating too. Three surfaces must stay in sync: backend router, agent server (if internal), MCP tool (if external). -14. **Pydantic Models Centralized in `models.py`** — Request/response models live in `models.py`, not scattered across routers. Keeps the API contract in one place. +14. **Pydantic Models Centralized in `models.py`** — Request/response models live in `models.py`, not scattered across routers (#654). Keeps the API contract in one place. **Scope:** this invariant governs **router** models — a `class X(BaseModel)` must not be defined under `routers/` (enforced by the static guard `tests/unit/test_models_centralized.py`). Two model homes are **intentionally separate** and out of scope: `db_models.py` (DB-row / persistence models — a distinct layer) and `adapters/base.py` (the ChannelAdapter ABC's `NormalizedMessage`/`ChannelResponse`). One documented exception, allowlisted in the guard: `routers/canary.py::RunCycleRequest` evaluates `INVARIANTS` (from the `canary` library) in a `Field(description=…)` at class-definition time, and the `canary` library imports `TaskExecutionStatus` back from `models` — relocating it would force `models.py` to `from canary import …`, inverting the dependency direction of a module meant to be a low-level leaf everything imports *from*. 15. **API URL Nesting Convention** — Agent-scoped resources nest under `/api/agents/{name}/...`. Platform-wide resources get top-level prefixes (`/api/executions`, `/api/operator-queue`). diff --git a/src/backend/models.py b/src/backend/models.py index a60b2c486..a23ac3a04 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1,8 +1,10 @@ """ Pydantic models for the Trinity backend API. """ -from pydantic import BaseModel, Field -from typing import Dict, List, Optional +import re + +from pydantic import BaseModel, EmailStr, Field, field_validator +from typing import Dict, List, Literal, Optional from datetime import datetime from enum import Enum @@ -874,3 +876,1161 @@ class CompatibilityFixResponse(BaseModel): fixed: bool message: str uncommitted: bool = True + + +# ============================================================================= +# Router-relocated request/response models (#654, INV-14) +# Each section below was moved verbatim from its router so Pydantic models +# live in one place (Architectural Invariant #14). One exception remains in +# routers/canary.py (RunCycleRequest) — see test_models_centralized.py. +# ============================================================================= + + +# ============================================================================= +# Agent Files Models (routers/agent_files.py) +# ============================================================================= + + +class FileUpdateRequest(BaseModel): + """Request body for file updates.""" + content: str + + +class CreateFolderRequest(BaseModel): + """Request body for folder creation.""" + path: str + + +# ============================================================================= +# Agent Rename Models (routers/agent_rename.py) +# ============================================================================= + + +class RenameAgentRequest(BaseModel): + """Request body for agent rename.""" + new_name: str + + +# ============================================================================= +# Agent Ssh Models (routers/agent_ssh.py) +# ============================================================================= + + +class SshAccessRequest(BaseModel): + """Request body for SSH access.""" + ttl_hours: float = 4.0 + auth_method: str = "key" # "key" for SSH key, "password" for ephemeral password + public_key: Optional[str] = None # Required for key auth — client-supplied OpenSSH public key + + +# ============================================================================= +# Agents Models (routers/agents.py) +# ============================================================================= + + +class HeartbeatPayload(BaseModel): + """Lightweight liveness payload POSTed by the agent every ~5s.""" + memory_mb: Optional[float] = None + active_executions: Optional[int] = None + uptime_s: Optional[float] = None + + +# ============================================================================= +# Audit Log Models (routers/audit_log.py) +# ============================================================================= + + +class AuditLogEntry(BaseModel): + """Single audit log row as returned to API clients.""" + + id: int + event_id: str + event_type: str + event_action: str + actor_type: str + actor_id: Optional[str] = None + actor_email: Optional[str] = None + actor_ip: Optional[str] = None + mcp_key_id: Optional[str] = None + mcp_key_name: Optional[str] = None + mcp_scope: Optional[str] = None + target_type: Optional[str] = None + target_id: Optional[str] = None + timestamp: str + details: Optional[dict] = None + request_id: Optional[str] = None + source: str + endpoint: Optional[str] = None + previous_hash: Optional[str] = None + entry_hash: Optional[str] = None + created_at: Optional[str] = None + + +class AuditLogListResponse(BaseModel): + """Paginated list response.""" + + entries: List[AuditLogEntry] + total: int + limit: int + offset: int + + +class AuditLogStatsResponse(BaseModel): + """Aggregate counts.""" + + total: int + by_event_type: dict = Field(default_factory=dict) + by_actor_type: dict = Field(default_factory=dict) + + +class AuditHeatmapCell(BaseModel): + """Single populated bucket in the 7×24 dow×hour heatmap.""" + + dow: int = Field(..., ge=0, le=6, description="Weekday (0=Sunday)") + hour: int = Field(..., ge=0, le=23, description="Hour 0–23 UTC") + count: int = Field(..., ge=0) + + +class AuditHeatmapResponse(BaseModel): + """Sparse 7×24 dow×hour heatmap. Zero-count cells omitted.""" + + cells: List[AuditHeatmapCell] + total: int + max_count: int + + +class AuditCalendarDay(BaseModel): + """Single populated day in the calendar heatmap.""" + + date: str = Field(..., description="UTC date, ISO 'YYYY-MM-DD'") + count: int = Field(..., ge=0) + + +class AuditCalendarResponse(BaseModel): + """Sparse per-day calendar heatmap (GitHub-style). Quiet days omitted.""" + + days: List[AuditCalendarDay] + total: int + max_count: int + + +class AuditVerifyResponse(BaseModel): + """Hash chain verification result.""" + + valid: bool + checked: int + first_invalid_id: Optional[int] = None + + +# ============================================================================= +# Avatar Models (routers/avatar.py) +# ============================================================================= + + +class AvatarGenerateRequest(BaseModel): + identity_prompt: str + + +# ============================================================================= +# Canary Models (routers/canary.py) +# ============================================================================= + + +class CanaryViolation(BaseModel): + """Single canary_violations row as returned to API clients.""" + + id: int + invariant_id: str + tier: str + severity: str + snapshot_time: str + observed_state: dict = Field(default_factory=dict) + signal_query: Optional[str] = None + created_at: Optional[str] = None + + +class CanaryViolationListResponse(BaseModel): + """Paginated list response.""" + + violations: List[CanaryViolation] + total: int + limit: int + offset: int + + +class CanaryStatsResponse(BaseModel): + """Aggregate violation counts for dashboard tiles.""" + + total: int + by_invariant: dict = Field(default_factory=dict) + by_severity: dict = Field(default_factory=dict) + + +class CycleViolation(BaseModel): + """One violation persisted during a run-cycle call.""" + + id: int + invariant_id: str + tier: str + severity: str + snapshot_time: str + observed_state: dict + signal_query: Optional[str] = None + + +class CycleTransition(BaseModel): + """A green→red transition detected this cycle. + + `CanaryService` posts exactly one Slack webhook message per entry, + mapping severity to the message styling. Surfaced here so the run-cycle + response mirrors what the service actually emitted. + """ + + invariant_id: str + severity: str + violations_in_cycle: int + previous_violation_at: Optional[str] = Field( + None, + description=( + "snapshot_time of the most recent prior violation for this " + "invariant; null if the invariant has never violated before." + ), + ) + + +class RunCycleResponse(BaseModel): + """Result of one canary cycle.""" + + snapshot_time: str + cycle_duration_ms: int + # Invariants this cycle attempted (= the request's `invariants` filter, + # or all registered ids if unfiltered). Whether each one *fired* is + # surfaced via `violations` and `transitions`. Sources that were down + # this cycle are listed in `sources_unavailable` — invariants that + # depend on them returned no violations regardless of state. + checks_run: List[str] + sources_unavailable: List[str] + violations: List[CycleViolation] + transitions: List[CycleTransition] + + +# ============================================================================= +# Event Subscriptions Models (routers/event_subscriptions.py) +# ============================================================================= + + +class EmitEventRequest(BaseModel): + """Request body for emitting an event.""" + event_type: str # Namespaced event type (e.g., "prediction.resolved") + payload: Optional[dict] = None # Structured data + + +# ============================================================================= +# Fan Out Models (routers/fan_out.py) +# ============================================================================= + + +TASK_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") + + +MAX_TASKS = 50 + + +MAX_CONCURRENCY = 10 + + +class FanOutTask(BaseModel): + """A single task in a fan-out request.""" + id: str + message: str = Field(..., min_length=1, max_length=100_000) + + @field_validator("id") + @classmethod + def validate_task_id(cls, v: str) -> str: + if not TASK_ID_RE.match(v): + raise ValueError( + f"Task ID must be 1-64 alphanumeric characters, hyphens, or underscores: '{v}'" + ) + return v + + +class FanOutRequest(BaseModel): + """Request model for fan-out parallel task execution.""" + tasks: List[FanOutTask] + agent: str = "self" + # Optional overall fan-out deadline. When None, no outer deadline is + # applied — each sub-task is still bounded by the target agent's + # configured execution_timeout_seconds (TIMEOUT-001). + timeout_seconds: Optional[int] = None + max_concurrency: int = 3 + policy: str = "best-effort" + model: Optional[str] = None + system_prompt: Optional[str] = None + allowed_tools: Optional[List[str]] = None + + @field_validator("tasks") + @classmethod + def validate_tasks(cls, v: List[FanOutTask]) -> List[FanOutTask]: + if len(v) == 0: + raise ValueError("At least one task is required") + if len(v) > MAX_TASKS: + raise ValueError(f"Maximum {MAX_TASKS} tasks per fan-out") + # Check for duplicate IDs + ids = [t.id for t in v] + if len(ids) != len(set(ids)): + dupes = [i for i in ids if ids.count(i) > 1] + raise ValueError(f"Duplicate task IDs: {set(dupes)}") + return v + + @field_validator("max_concurrency") + @classmethod + def validate_concurrency(cls, v: int) -> int: + if v < 1 or v > MAX_CONCURRENCY: + raise ValueError(f"max_concurrency must be between 1 and {MAX_CONCURRENCY}") + return v + + @field_validator("timeout_seconds") + @classmethod + def validate_timeout(cls, v: Optional[int]) -> Optional[int]: + if v is None: + return v + if v < 10 or v > 3600: + raise ValueError("timeout_seconds must be between 10 and 3600") + return v + + @field_validator("policy") + @classmethod + def validate_policy(cls, v: str) -> str: + if v != "best-effort": + raise ValueError("Only 'best-effort' policy is supported") + return v + + +class FanOutTaskResponse(BaseModel): + """Result of a single fan-out subtask.""" + id: str + status: str + response: Optional[str] = None + error: Optional[str] = None + error_code: Optional[str] = None + execution_id: Optional[str] = None + cost: Optional[float] = None + context_used: Optional[int] = None + duration_ms: Optional[int] = None + + +class FanOutResponse(BaseModel): + """Aggregated fan-out result.""" + fan_out_id: str + status: str + total: int + completed: int + failed: int + results: List[FanOutTaskResponse] + + +# ============================================================================= +# Git Models (routers/git.py) +# ============================================================================= + + +class GitSyncRequest(BaseModel): + """Request body for git sync operation.""" + message: Optional[str] = None # Custom commit message + paths: Optional[List[str]] = None # Specific paths to sync + strategy: Optional[str] = "normal" # "normal", "pull_first", "force_push" + + +class GitPullRequest(BaseModel): + """Request body for git pull operation.""" + strategy: Optional[str] = "clean" # "clean", "stash_reapply", "force_reset" + + +class GitInitializeRequest(BaseModel): + """Request body for git initialization.""" + repo_owner: str # GitHub username or organization + repo_name: str # Repository name + create_repo: bool = True # Whether to create the repository if it doesn't exist + private: bool = True # Whether the new repository should be private + description: Optional[str] = None # Repository description + + +class GitHubPATRequest(BaseModel): + """Request body for setting agent GitHub PAT.""" + pat: str + + +class AutoSyncToggle(BaseModel): + enabled: bool + + +class FreezeSchedulesToggle(BaseModel): + enabled: bool + + +# ============================================================================= +# Image Generation Models (routers/image_generation.py) +# ============================================================================= + + +class ImageGenerateRequest(BaseModel): + prompt: str + use_case: Optional[str] = "general" + aspect_ratio: Optional[str] = "1:1" + refine_prompt: Optional[bool] = True + + +# ============================================================================= +# Internal Models (routers/internal.py) +# ============================================================================= + + +class ActivityTrackRequest(BaseModel): + """Request model for tracking activity start.""" + agent_name: str + activity_type: str # e.g., "schedule_start" + user_id: Optional[int] = None + triggered_by: str = "schedule" # schedule, manual, user, agent, system + related_execution_id: Optional[str] = None + details: Optional[Dict] = None + + +class ActivityCompleteRequest(BaseModel): + """Request model for completing an activity.""" + status: str = ActivityState.COMPLETED # ActivityState: completed, failed + details: Optional[Dict] = None + error: Optional[str] = None + + +class InternalTaskExecutionRequest(BaseModel): + """Request model for internal task execution via TaskExecutionService.""" + agent_name: str + message: str + triggered_by: str = "schedule" + model: Optional[str] = None + timeout_seconds: Optional[int] = None # TIMEOUT-001: None = use agent's config (default 15 min) + allowed_tools: Optional[List[str]] = None + execution_id: Optional[str] = None + async_mode: bool = False + # #171: optional schedule metadata surfaced in the agent's execution context block. + schedule_name: Optional[str] = None + schedule_cron: Optional[str] = None + schedule_next_run: Optional[str] = None + attempt: Optional[int] = None + + +class ValidateExecutionRequest(BaseModel): + """Request model for triggering execution validation.""" + execution_id: str + agent_name: str + schedule_id: str + original_message: str + execution_response: str + custom_prompt: Optional[str] = None + timeout_seconds: int = 120 + + +class InternalAuditRequest(BaseModel): + """Request model for audit log entries from MCP server.""" + event_type: str # AuditEventType value + event_action: str # e.g. "tool_call" + source: str = "mcp" # Always "mcp" for MCP server calls + # MCP auth context + mcp_key_id: Optional[str] = None + mcp_key_name: Optional[str] = None + mcp_scope: Optional[str] = None + actor_agent_name: Optional[str] = None + # Target + target_type: Optional[str] = None + target_id: Optional[str] = None + # Details + details: Optional[Dict] = None + + +# ============================================================================= +# Logs Models (routers/logs.py) +# ============================================================================= + + +class RetentionConfig(BaseModel): + """Retention configuration.""" + retention_days: int = Field(..., ge=1, le=3650, description="Days to retain logs") + archive_enabled: bool = Field(..., description="Whether archival is enabled") + cleanup_hour: int = Field(..., ge=0, le=23, description="Hour (UTC) to run nightly archival") + + +class ArchiveRequest(BaseModel): + """Manual archive request.""" + retention_days: Optional[int] = Field(None, ge=1, le=3650, description="Override retention days") + delete_after_archive: bool = Field(True, description="Delete originals after archiving") + + +# ============================================================================= +# Loops Models (routers/loops.py) +# ============================================================================= + + +MAX_RUNS_LIMIT = 100 + + +MAX_MESSAGE_LEN = 100_000 + + +MAX_DELAY_SECONDS = 3600 + + +MAX_TIMEOUT_PER_RUN = 7200 + + +MAX_STOP_SIGNAL_LEN = 200 + + +class StartLoopRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=MAX_MESSAGE_LEN) + max_runs: int = Field(..., ge=1, le=MAX_RUNS_LIMIT) + stop_signal: Optional[str] = Field(default=None, max_length=MAX_STOP_SIGNAL_LEN) + delay_seconds: int = Field(default=0, ge=0, le=MAX_DELAY_SECONDS) + timeout_per_run: Optional[int] = Field(default=None, ge=10, le=MAX_TIMEOUT_PER_RUN) + model: Optional[str] = None + allowed_tools: Optional[List[str]] = None + + @field_validator("stop_signal") + @classmethod + def _normalize_stop_signal(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + return v or None # empty after strip → fixed mode + + +class StartLoopResponse(BaseModel): + loop_id: str + status: str + agent_name: str + max_runs: int + + +class LoopRunResponse(BaseModel): + run_number: int + execution_id: Optional[str] = None + status: str + response_preview: Optional[str] = None + cost: Optional[float] = None + duration_ms: Optional[int] = None + error: Optional[str] = None + started_at: str + completed_at: Optional[str] = None + + +class LoopStatusResponse(BaseModel): + loop_id: str + agent_name: str + status: str + max_runs: int + runs_completed: int + stop_reason: Optional[str] = None + last_response: Optional[str] = None + error: Optional[str] = None + runs: List[LoopRunResponse] + created_at: str + started_at: Optional[str] = None + completed_at: Optional[str] = None + + +class StopLoopResponse(BaseModel): + loop_id: str + status: str # "stopping" | "already_done" + + +# ============================================================================= +# Messages Models (routers/messages.py) +# ============================================================================= + + +class SendMessageRequest(BaseModel): + """Request to send a proactive message to a user.""" + recipient_email: EmailStr = Field( + ..., + description="Verified email of the recipient. Must be in agent_sharing with allow_proactive=1." + ) + text: str = Field( + ..., + min_length=1, + max_length=4096, + description="Message content (max 4096 characters)" + ) + channel: Literal["auto", "telegram", "slack", "web"] = Field( + default="auto", + description="Target channel. 'auto' tries channels in order: telegram -> slack -> web" + ) + reply_to_thread: bool = Field( + default=False, + description="Continue in last thread if one exists (channel-dependent)" + ) + + +class SendMessageResponse(BaseModel): + """Response from sending a proactive message.""" + success: bool + channel: str + message_id: Optional[str] = None + error: Optional[str] = None + + +class ProactiveShareUpdate(BaseModel): + """Request to update allow_proactive flag for a share.""" + email: EmailStr + allow_proactive: bool + + +class ProactiveSharesResponse(BaseModel): + """List of emails with proactive messaging enabled.""" + agent_name: str + emails: list[str] + + +# ============================================================================= +# Notifications Models (routers/notifications.py) +# ============================================================================= + + +class DismissAllRequest(BaseModel): + """Body for bulk-dismissing notifications (#1017).""" + agent_name: Optional[str] = None + + +# ============================================================================= +# Operator Queue Models (routers/operator_queue.py) +# ============================================================================= + + +class OperatorResponse(BaseModel): + """Body for responding to a queue item.""" + response: str + response_text: Optional[str] = None + + +class BulkCancelRequest(BaseModel): + """Body for bulk-cancelling pending queue items (#1017). + + The client sends the ids it actually rendered, so a sync-loop race can + never cancel items the operator never saw. + """ + ids: List[str] = Field(..., min_length=1, max_length=500) + + +class ClearResolvedRequest(BaseModel): + """Body for clearing the Resolved tab (#1017).""" + agent_name: Optional[str] = None + + +# ============================================================================= +# Paid Models (routers/paid.py) +# ============================================================================= + + +class PaidChatRequest(BaseModel): + message: str + session_id: Optional[str] = None + + +# ============================================================================= +# Public Models (routers/public.py) +# ============================================================================= + + +class PublicChatHistoryResponse(BaseModel): + """Response model for chat history endpoint.""" + messages: List[dict] + session_id: str + message_count: int + + +class ClearSessionResponse(BaseModel): + """Response model for clear session endpoint.""" + cleared: bool + new_session_id: Optional[str] = None + + +# ============================================================================= +# Public Memory Models (routers/public_memory.py) +# ============================================================================= + + +class WriteUserMemoryRequest(BaseModel): + execution_id: str = Field(..., min_length=1, max_length=200) + memory_text: str = Field(..., max_length=8000) + + +# ============================================================================= +# Schedules Models (routers/schedules.py) +# ============================================================================= + + +class ScheduleUpdateRequest(BaseModel): + """Request model for updating a schedule.""" + name: Optional[str] = None + cron_expression: Optional[str] = None + message: Optional[str] = None + enabled: Optional[bool] = None + timezone: Optional[str] = None + description: Optional[str] = None + timeout_seconds: Optional[int] = None + allowed_tools: Optional[List[str]] = None + model: Optional[str] = None # Model override (MODEL-001) + # Retry configuration (RETRY-001) + max_retries: Optional[int] = None + retry_delay_seconds: Optional[int] = None + # Validation configuration (VALIDATE-001) + validation_enabled: Optional[bool] = None + validation_prompt: Optional[str] = None + validation_timeout_seconds: Optional[int] = None + + +class ScheduleResponse(BaseModel): + """Response model for schedule data.""" + id: str + agent_name: str + name: str + cron_expression: str + message: str + enabled: bool + timezone: str + description: Optional[str] + created_at: datetime + updated_at: datetime + last_run_at: Optional[datetime] + next_run_at: Optional[datetime] + # #913: null means "inherit from agent_ownership.execution_timeout_seconds". + timeout_seconds: Optional[int] = None + allowed_tools: Optional[List[str]] = None + model: Optional[str] = None # Model override (MODEL-001) + # Validation configuration (VALIDATE-001) + validation_enabled: bool = False + validation_prompt: Optional[str] = None + validation_timeout_seconds: int = 120 + + class Config: + from_attributes = True + + +class ExecutionSummary(BaseModel): + """Lightweight execution response for list views - excludes large text fields. + + Used by GET /api/agents/{name}/executions for fast list loading. + Full details available via GET /api/agents/{name}/executions/{id}. + """ + id: str + schedule_id: str + agent_name: str + status: str + started_at: datetime + completed_at: Optional[datetime] + duration_ms: Optional[int] + message: str + triggered_by: str + # Observability fields (small) + context_used: Optional[int] = None + context_max: Optional[int] = None + cost: Optional[float] = None + # Origin tracking (small) - AUDIT-001 + source_user_id: Optional[int] = None + source_user_email: Optional[str] = None + source_agent_name: Optional[str] = None + source_mcp_key_id: Optional[str] = None + source_mcp_key_name: Optional[str] = None + # Session resume (small) - EXEC-023 + claude_session_id: Optional[str] = None + # Model selection (small) - MODEL-001 + model_used: Optional[str] = None + # Fan-out linkage (small) - FANOUT-001 + fan_out_id: Optional[str] = None + # Validation tracking (small) - VALIDATE-001 + business_status: Optional[str] = None # pending_validation, validated, failed_validation, skipped + validation_execution_id: Optional[str] = None + # Auto-compact observability (Bundle B) - small JSON list + compact_metadata: Optional[str] = None + + # EXCLUDED (large fields - fetch via /executions/{id}): + # - response: Optional[str] # Full response text + # - error: Optional[str] # Full error text + # - tool_calls: Optional[str] # JSON array of tool calls + # - execution_log: Optional[str] # Full Claude Code transcript + + class Config: + from_attributes = True + + +class ExecutionResponse(BaseModel): + """Full response model for execution data - includes all fields. + + Used by GET /api/agents/{name}/executions/{id} for single execution details. + """ + id: str + schedule_id: str + agent_name: str + status: str + started_at: datetime + completed_at: Optional[datetime] + duration_ms: Optional[int] + message: str + response: Optional[str] + error: Optional[str] + triggered_by: str + # Observability fields + context_used: Optional[int] = None + context_max: Optional[int] = None + cost: Optional[float] = None + tool_calls: Optional[str] = None + execution_log: Optional[str] = None # Full Claude Code execution transcript (JSON) + # Origin tracking - AUDIT-001 + source_user_id: Optional[int] = None + source_user_email: Optional[str] = None + source_agent_name: Optional[str] = None + source_mcp_key_id: Optional[str] = None + source_mcp_key_name: Optional[str] = None + # Session resume - EXEC-023 + claude_session_id: Optional[str] = None + # Model selection - MODEL-001 + model_used: Optional[str] = None + # Fan-out linkage - FANOUT-001 + fan_out_id: Optional[str] = None + # Validation tracking - VALIDATE-001 + business_status: Optional[str] = None + validated_at: Optional[datetime] = None + validation_execution_id: Optional[str] = None + validates_execution_id: Optional[str] = None + # Auto-compact observability (Bundle B) + compact_metadata: Optional[str] = None + + class Config: + from_attributes = True + + +class WebhookStatusResponse(BaseModel): + """Webhook configuration for a schedule.""" + schedule_id: str + has_token: bool + webhook_enabled: bool + webhook_url: Optional[str] = None + + +# ============================================================================= +# Sessions Models (routers/sessions.py) +# ============================================================================= + + +class CreateSessionRequest(BaseModel): + """Optional body for POST /session. All fields optional.""" + + subscription_id: Optional[str] = None + + +class SessionMessageRequest(BaseModel): + """Body for the turn endpoint.""" + + message: str = Field(..., min_length=1) + model: Optional[str] = None + timeout_seconds: Optional[int] = None + # File attachments — same shape as ParallelTaskRequest.files (#364). + # Images become vision blocks for the model; non-images are written + # into the agent workspace and a "[File uploaded by X]: name (size) + # saved to path" line is appended to the prompt so the agent can + # `Read` them. (Phase 5.2 file-upload parity with Chat.) + files: Optional[list] = None + + +# ============================================================================= +# Settings Models (routers/settings.py) +# ============================================================================= + + +class ApiKeyUpdate(BaseModel): + """Request body for updating an API key.""" + api_key: str + + +class ApiKeyTest(BaseModel): + """Request body for testing an API key.""" + api_key: str + + +class OpsSettingsUpdate(BaseModel): + """Request body for updating ops settings.""" + settings: Dict[str, str] + + +class SlackSettingsUpdate(BaseModel): + """Request body for updating Slack settings.""" + client_id: str = None + client_secret: str = None + signing_secret: str = None + + +class SlackConnectRequest(BaseModel): + """Request body for connecting Slack transport.""" + app_token: Optional[str] = None # xapp-... for Socket Mode + transport_mode: Optional[str] = None # "socket" or "webhook" + + +class GitHubTemplateEntry(BaseModel): + """A single GitHub template entry.""" + github_repo: str + display_name: str = "" + description: str = "" + + +class GitHubTemplatesUpdate(BaseModel): + """Request body for updating GitHub templates.""" + templates: List[GitHubTemplateEntry] + + +class McpUrlUpdate(BaseModel): + """Request body for updating the MCP server URL.""" + url: str + + +class AgentQuotaUpdate(BaseModel): + """Request body for updating per-role agent quotas.""" + max_agents_creator: Optional[str] = None + max_agents_operator: Optional[str] = None + max_agents_user: Optional[str] = None + + +# ============================================================================= +# Setup Models (routers/setup.py) +# ============================================================================= + + +class SetAdminPasswordRequest(BaseModel): + """Request body for creating the admin account at first-time setup. + + `email` is **required** (trinity-enterprise#49): it becomes the admin's + sign-in identity (login with email + password instead of the fixed 'admin') + and is the contact used for the optional operator intake. The remaining + operator-profile fields (company/name/role/use_case) stay optional and are + only forwarded to the hosted intake endpoint when `consent_updates` is true. + """ + password: str = Field(..., max_length=128) + confirm_password: str = Field(..., max_length=128) + # Required admin email — sign-in identity. Shape validated in the handler so + # a typo / blank value yields a clean 400 (a missing field yields a 422). + email: str = Field(..., max_length=254) + # Optional operator profile — all skippable; setup completes without them. + company: Optional[str] = Field(None, max_length=200) + name: Optional[str] = Field(None, max_length=200) + role: Optional[str] = Field(None, max_length=200) + use_case: Optional[str] = Field(None, max_length=500) + # Affirmative, opt-in consent to occasionally receive security & product + # updates. ONLY when true is anything submitted to the hosted intake. + consent_updates: bool = False + + +# ============================================================================= +# Sharing Models (routers/sharing.py) +# ============================================================================= + + +class AccessPolicy(BaseModel): + require_email: bool + open_access: bool + group_auth_mode: str = "none" # 'none' or 'any_verified' + + +class AccessPolicyUpdate(BaseModel): + require_email: bool + open_access: bool + group_auth_mode: str = "none" # 'none' or 'any_verified' + + +class AccessRequest(BaseModel): + id: str + agent_name: str + email: str + channel: str | None = None + requested_at: str + status: str + + +class AccessRequestDecision(BaseModel): + approve: bool + + +# ============================================================================= +# Slack Models (routers/slack.py) +# ============================================================================= + + +class SlackEventResponse(BaseModel): + """Response to Slack events (always return 200).""" + ok: bool = True + challenge: Optional[str] = None + + +# ============================================================================= +# Telegram Models (routers/telegram.py) +# ============================================================================= + + +class TelegramWebhookResponse(BaseModel): + ok: bool = True + + +class TelegramBindingResponse(BaseModel): + agent_name: str + bot_username: Optional[str] = None + bot_id: Optional[str] = None + webhook_url: Optional[str] = None + bot_link: Optional[str] = None + configured: bool = False + group_count: int = 0 + warning: Optional[str] = None + + +class TelegramConfigureRequest(BaseModel): + bot_token: str + + +class TelegramTestRequest(BaseModel): + chat_id: Optional[str] = None + message: str = "Hello from Trinity! Your Telegram bot is configured correctly." + + +class TelegramGroupConfigResponse(BaseModel): + id: int + chat_id: str + chat_title: Optional[str] = None + chat_type: str = "group" + trigger_mode: str = "mention" + welcome_enabled: bool = False + welcome_text: Optional[str] = None + is_active: bool = True + + +class TelegramGroupConfigUpdateRequest(BaseModel): + trigger_mode: Optional[str] = None + welcome_enabled: Optional[bool] = None + welcome_text: Optional[str] = None + + +class TelegramGroupMessageRequest(BaseModel): + """Request model for proactive group messaging (Issue #349).""" + message: str + + +# ============================================================================= +# Users Models (routers/users.py) +# ============================================================================= + + +class UserRoleUpdate(BaseModel): + role: str + + +class UpdateMyEmailRequest(BaseModel): + email: str + + +# ============================================================================= +# Voice Models (routers/voice.py) +# ============================================================================= + + +class VoiceStartRequest(BaseModel): + session_id: Optional[str] = None # Existing chat session to continue + voice_name: Optional[str] = None # Gemini voice name (e.g. "Kore", "Puck") + workspace_mode: bool = False # Enable canvas panel tools + + +class VoiceStartResponse(BaseModel): + voice_session_id: str + websocket_url: str + chat_session_id: str + + +class VoiceStopRequest(BaseModel): + voice_session_id: str + + +class VoiceStopResponse(BaseModel): + transcript: list + messages_saved: int + duration_seconds: float + + +# ============================================================================= +# Voip Models (routers/voip.py) +# ============================================================================= + + +class VoipConfigureRequest(BaseModel): + account_sid: str + auth_token: str + from_number: str + daily_call_cap: Optional[int] = None + + +class VoipBindingResponse(BaseModel): + agent_name: str + configured: bool + account_sid: Optional[str] = None + from_number: Optional[str] = None + daily_call_cap: Optional[int] = None + display_name: Optional[str] = None + enabled: Optional[bool] = None + + +class VoipCallRequest(BaseModel): + to_number: str + context: Optional[str] = None + process_transcript: bool = True + + +# ============================================================================= +# Webhooks Models (routers/webhooks.py) +# ============================================================================= + + +CONTEXT_MAX_CHARS = 4000 + + +class WebhookTriggerRequest(BaseModel): + """Optional body for a webhook trigger call.""" + context: Optional[str] = Field( + default=None, + description="Additional context appended to the schedule message.", + max_length=CONTEXT_MAX_CHARS, + ) + metadata: Optional[dict] = Field( + default=None, + description="Arbitrary key/value metadata stored on the execution record.", + ) + + +# ============================================================================= +# Whatsapp Models (routers/whatsapp.py) +# ============================================================================= + + +class WhatsAppBindingResponse(BaseModel): + agent_name: str + configured: bool = False + account_sid: Optional[str] = None + from_number: Optional[str] = None + messaging_service_sid: Optional[str] = None + display_name: Optional[str] = None + is_sandbox: bool = False + webhook_url: Optional[str] = None + warning: Optional[str] = None + + +class WhatsAppConfigureRequest(BaseModel): + account_sid: str + auth_token: str + from_number: str + messaging_service_sid: Optional[str] = None + + +class WhatsAppTestRequest(BaseModel): + to_number: Optional[str] = None + message: str = "Hello from Trinity! Your WhatsApp integration is configured correctly." diff --git a/src/backend/routers/agent_files.py b/src/backend/routers/agent_files.py index 8f9400a32..f5358dcbf 100644 --- a/src/backend/routers/agent_files.py +++ b/src/backend/routers/agent_files.py @@ -2,7 +2,6 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel from models import User from database import db @@ -29,7 +28,14 @@ get_file_sharing_status_logic, set_file_sharing_status_logic, ) -from models import ShareFileMcpRequest, ShareFileResponse, SharedFileInfo, SharedFilesList +from models import ( + CreateFolderRequest, + FileUpdateRequest, + ShareFileMcpRequest, + ShareFileResponse, + SharedFileInfo, + SharedFilesList, +) from services.agent_shared_files_service import ( create_share, build_download_url, @@ -212,11 +218,6 @@ async def delete_agent_file_endpoint( return await delete_agent_file_logic(agent_name, path, current_user, request) -class FileUpdateRequest(BaseModel): - """Request body for file updates.""" - content: str - - @router.put("/{agent_name}/files") async def update_agent_file_endpoint( agent_name: str, @@ -234,11 +235,6 @@ async def update_agent_file_endpoint( return await update_agent_file_logic(agent_name, path, body.content, current_user, request) -class CreateFolderRequest(BaseModel): - """Request body for folder creation.""" - path: str - - @router.post("/{agent_name}/files/mkdir") async def create_agent_folder_endpoint( agent_name: str, diff --git a/src/backend/routers/agent_rename.py b/src/backend/routers/agent_rename.py index 5b5a6f348..bfd60b25e 100644 --- a/src/backend/routers/agent_rename.py +++ b/src/backend/routers/agent_rename.py @@ -5,9 +5,8 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel -from models import User +from models import RenameAgentRequest, User from database import db from dependencies import get_current_user from services.docker_service import get_agent_container @@ -35,11 +34,6 @@ def set_filtered_websocket_manager(ws_manager): filtered_manager = ws_manager -class RenameAgentRequest(BaseModel): - """Request body for agent rename.""" - new_name: str - - @router.put("/{agent_name}/rename") async def rename_agent_endpoint( agent_name: str, diff --git a/src/backend/routers/agent_ssh.py b/src/backend/routers/agent_ssh.py index d3b05d97f..e3c9360b1 100644 --- a/src/backend/routers/agent_ssh.py +++ b/src/backend/routers/agent_ssh.py @@ -1,12 +1,10 @@ """SSH access endpoints for Trinity agents.""" import time -from typing import Optional from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from models import User +from models import SshAccessRequest, User from dependencies import require_admin from services.docker_service import get_agent_container from services.docker_utils import container_reload @@ -14,13 +12,6 @@ router = APIRouter(prefix="/api/agents", tags=["agents"]) -class SshAccessRequest(BaseModel): - """Request body for SSH access.""" - ttl_hours: float = 4.0 - auth_method: str = "key" # "key" for SSH key, "password" for ephemeral password - public_key: Optional[str] = None # Required for key auth — client-supplied OpenSSH public key - - @router.post("/{agent_name}/ssh-access") async def create_ssh_access( agent_name: str, diff --git a/src/backend/routers/agents.py b/src/backend/routers/agents.py index 6bc12270c..a82576a05 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -14,16 +14,16 @@ import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Query, WebSocket -from pydantic import BaseModel from models import ( AgentConfig, AgentStatus, - User, - DeployLocalRequest, CircuitBreakerConfigUpdate, + DeployLocalRequest, ExecutionResultEnvelope, + HeartbeatPayload, TaskExecutionStatus, + User, ) from database import db from dependencies import get_current_user, decode_token, require_role, AuthorizedAgentByName, OwnedAgentByName, CurrentUser @@ -781,12 +781,6 @@ async def set_circuit_breaker_endpoint( # Heartbeat Endpoint (RELIABILITY-004 / #307) # ============================================================================ -class HeartbeatPayload(BaseModel): - """Lightweight liveness payload POSTed by the agent every ~5s.""" - memory_mb: Optional[float] = None - active_executions: Optional[int] = None - uptime_s: Optional[float] = None - @router.post("/{agent_name}/heartbeat") async def agent_heartbeat(agent_name: str, payload: HeartbeatPayload, request: Request): diff --git a/src/backend/routers/audit_log.py b/src/backend/routers/audit_log.py index 7b7b5c8ab..25e13ac56 100644 --- a/src/backend/routers/audit_log.py +++ b/src/backend/routers/audit_log.py @@ -18,11 +18,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field from database import db from dependencies import require_admin -from models import User +from models import ( + AuditCalendarResponse, + AuditHeatmapResponse, + AuditLogEntry, + AuditLogListResponse, + AuditLogStatsResponse, + AuditVerifyResponse, + User, +) from services.platform_audit_service import platform_audit_service logger = logging.getLogger(__name__) @@ -30,85 +37,6 @@ router = APIRouter(prefix="/api/audit-log", tags=["audit-log"]) -# --------------------------------------------------------------------------- -# Response models -# --------------------------------------------------------------------------- - - -class AuditLogEntry(BaseModel): - """Single audit log row as returned to API clients.""" - - id: int - event_id: str - event_type: str - event_action: str - actor_type: str - actor_id: Optional[str] = None - actor_email: Optional[str] = None - actor_ip: Optional[str] = None - mcp_key_id: Optional[str] = None - mcp_key_name: Optional[str] = None - mcp_scope: Optional[str] = None - target_type: Optional[str] = None - target_id: Optional[str] = None - timestamp: str - details: Optional[dict] = None - request_id: Optional[str] = None - source: str - endpoint: Optional[str] = None - previous_hash: Optional[str] = None - entry_hash: Optional[str] = None - created_at: Optional[str] = None - - -class AuditLogListResponse(BaseModel): - """Paginated list response.""" - - entries: List[AuditLogEntry] - total: int - limit: int - offset: int - - -class AuditLogStatsResponse(BaseModel): - """Aggregate counts.""" - - total: int - by_event_type: dict = Field(default_factory=dict) - by_actor_type: dict = Field(default_factory=dict) - - -class AuditHeatmapCell(BaseModel): - """Single populated bucket in the 7×24 dow×hour heatmap.""" - - dow: int = Field(..., ge=0, le=6, description="Weekday (0=Sunday)") - hour: int = Field(..., ge=0, le=23, description="Hour 0–23 UTC") - count: int = Field(..., ge=0) - - -class AuditHeatmapResponse(BaseModel): - """Sparse 7×24 dow×hour heatmap. Zero-count cells omitted.""" - - cells: List[AuditHeatmapCell] - total: int - max_count: int - - -class AuditCalendarDay(BaseModel): - """Single populated day in the calendar heatmap.""" - - date: str = Field(..., description="UTC date, ISO 'YYYY-MM-DD'") - count: int = Field(..., ge=0) - - -class AuditCalendarResponse(BaseModel): - """Sparse per-day calendar heatmap (GitHub-style). Quiet days omitted.""" - - days: List[AuditCalendarDay] - total: int - max_count: int - - # --------------------------------------------------------------------------- # Endpoints — all admin-only # --------------------------------------------------------------------------- @@ -176,14 +104,6 @@ async def audit_log_calendar( # --------------------------------------------------------------------------- -class AuditVerifyResponse(BaseModel): - """Hash chain verification result.""" - - valid: bool - checked: int - first_invalid_id: Optional[int] = None - - @router.post("/verify", response_model=AuditVerifyResponse) async def verify_audit_integrity( start_id: int = Query(..., ge=1, description="First row ID to verify"), diff --git a/src/backend/routers/avatar.py b/src/backend/routers/avatar.py index a23ceffb3..ce3f17a86 100644 --- a/src/backend/routers/avatar.py +++ b/src/backend/routers/avatar.py @@ -12,11 +12,10 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse, JSONResponse -from pydantic import BaseModel from database import db from dependencies import get_current_user -from models import User +from models import AvatarGenerateRequest, User from services.agent_auth import agent_httpx_client from services.image_generation_prompts import AVATAR_EMOTIONS, AVATAR_EMOTION_PROMPTS from services.image_generation_service import get_image_generation_service @@ -92,10 +91,6 @@ def _get_style_for_agent(agent_name: str) -> str: return _DEFAULT_AVATAR_STYLES[h % len(_DEFAULT_AVATAR_STYLES)] -class AvatarGenerateRequest(BaseModel): - identity_prompt: str - - async def _get_prompt_from_template(agent_name: str) -> Optional[str]: """Fetch avatar_prompt or build from description via agent's template.yaml. diff --git a/src/backend/routers/canary.py b/src/backend/routers/canary.py index 5cbea0a05..7374dadcf 100644 --- a/src/backend/routers/canary.py +++ b/src/backend/routers/canary.py @@ -21,7 +21,15 @@ from canary import INVARIANTS from database import db from dependencies import require_admin -from models import User +from models import ( + CanaryStatsResponse, + CanaryViolation, + CanaryViolationListResponse, + CycleTransition, + CycleViolation, + RunCycleResponse, + User, +) from services.canary_alerts import severity_rank from services.canary_service import canary_service @@ -31,42 +39,10 @@ # --------------------------------------------------------------------------- -# Response models -# --------------------------------------------------------------------------- - - -class CanaryViolation(BaseModel): - """Single canary_violations row as returned to API clients.""" - - id: int - invariant_id: str - tier: str - severity: str - snapshot_time: str - observed_state: dict = Field(default_factory=dict) - signal_query: Optional[str] = None - created_at: Optional[str] = None - - -class CanaryViolationListResponse(BaseModel): - """Paginated list response.""" - - violations: List[CanaryViolation] - total: int - limit: int - offset: int - - -class CanaryStatsResponse(BaseModel): - """Aggregate violation counts for dashboard tiles.""" - - total: int - by_invariant: dict = Field(default_factory=dict) - by_severity: dict = Field(default_factory=dict) - - -# --------------------------------------------------------------------------- -# Run-cycle request / response models +# Run-cycle request model — stays in this router (not models.py) because it +# evaluates `INVARIANTS` (from the canary library) at class-definition time, +# and the canary library imports back from models; centralizing it would +# invert the dependency direction. Allowlisted in test_models_centralized.py. # --------------------------------------------------------------------------- @@ -82,54 +58,6 @@ class RunCycleRequest(BaseModel): ) -class CycleViolation(BaseModel): - """One violation persisted during a run-cycle call.""" - - id: int - invariant_id: str - tier: str - severity: str - snapshot_time: str - observed_state: dict - signal_query: Optional[str] = None - - -class CycleTransition(BaseModel): - """A green→red transition detected this cycle. - - `CanaryService` posts exactly one Slack webhook message per entry, - mapping severity to the message styling. Surfaced here so the run-cycle - response mirrors what the service actually emitted. - """ - - invariant_id: str - severity: str - violations_in_cycle: int - previous_violation_at: Optional[str] = Field( - None, - description=( - "snapshot_time of the most recent prior violation for this " - "invariant; null if the invariant has never violated before." - ), - ) - - -class RunCycleResponse(BaseModel): - """Result of one canary cycle.""" - - snapshot_time: str - cycle_duration_ms: int - # Invariants this cycle attempted (= the request's `invariants` filter, - # or all registered ids if unfiltered). Whether each one *fired* is - # surfaced via `violations` and `transitions`. Sources that were down - # this cycle are listed in `sources_unavailable` — invariants that - # depend on them returned no violations regardless of state. - checks_run: List[str] - sources_unavailable: List[str] - violations: List[CycleViolation] - transitions: List[CycleTransition] - - # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- diff --git a/src/backend/routers/event_subscriptions.py b/src/backend/routers/event_subscriptions.py index 104af844c..5cae43804 100644 --- a/src/backend/routers/event_subscriptions.py +++ b/src/backend/routers/event_subscriptions.py @@ -13,7 +13,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from models import EmitEventRequest from database import db from dependencies import get_current_user, AuthorizedAgent, OwnedAgent @@ -356,11 +356,6 @@ async def delete_event_subscription( # Event Emission Endpoint # ============================================================================ -class EmitEventRequest(BaseModel): - """Request body for emitting an event.""" - event_type: str # Namespaced event type (e.g., "prediction.resolved") - payload: Optional[dict] = None # Structured data - @router.post("/events", response_model=AgentEvent, status_code=201) async def emit_event( diff --git a/src/backend/routers/fan_out.py b/src/backend/routers/fan_out.py index 7ec56f268..dd869375d 100644 --- a/src/backend/routers/fan_out.py +++ b/src/backend/routers/fan_out.py @@ -7,15 +7,13 @@ """ import logging -import re -from typing import List, Optional +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Header from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field, field_validator from dependencies import get_current_user, get_authorized_agent -from models import User +from models import FanOutRequest, FanOutResponse, FanOutTaskResponse, User from services.fan_out_service import ( FanOutService, FanOutTaskInput, @@ -28,105 +26,6 @@ router = APIRouter(prefix="/api/agents", tags=["fan-out"]) -# --------------------------------------------------------------------------- -# Request / Response models -# --------------------------------------------------------------------------- - -TASK_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") -MAX_TASKS = 50 -MAX_CONCURRENCY = 10 - - -class FanOutTask(BaseModel): - """A single task in a fan-out request.""" - id: str - message: str = Field(..., min_length=1, max_length=100_000) - - @field_validator("id") - @classmethod - def validate_task_id(cls, v: str) -> str: - if not TASK_ID_RE.match(v): - raise ValueError( - f"Task ID must be 1-64 alphanumeric characters, hyphens, or underscores: '{v}'" - ) - return v - - -class FanOutRequest(BaseModel): - """Request model for fan-out parallel task execution.""" - tasks: List[FanOutTask] - agent: str = "self" - # Optional overall fan-out deadline. When None, no outer deadline is - # applied — each sub-task is still bounded by the target agent's - # configured execution_timeout_seconds (TIMEOUT-001). - timeout_seconds: Optional[int] = None - max_concurrency: int = 3 - policy: str = "best-effort" - model: Optional[str] = None - system_prompt: Optional[str] = None - allowed_tools: Optional[List[str]] = None - - @field_validator("tasks") - @classmethod - def validate_tasks(cls, v: List[FanOutTask]) -> List[FanOutTask]: - if len(v) == 0: - raise ValueError("At least one task is required") - if len(v) > MAX_TASKS: - raise ValueError(f"Maximum {MAX_TASKS} tasks per fan-out") - # Check for duplicate IDs - ids = [t.id for t in v] - if len(ids) != len(set(ids)): - dupes = [i for i in ids if ids.count(i) > 1] - raise ValueError(f"Duplicate task IDs: {set(dupes)}") - return v - - @field_validator("max_concurrency") - @classmethod - def validate_concurrency(cls, v: int) -> int: - if v < 1 or v > MAX_CONCURRENCY: - raise ValueError(f"max_concurrency must be between 1 and {MAX_CONCURRENCY}") - return v - - @field_validator("timeout_seconds") - @classmethod - def validate_timeout(cls, v: Optional[int]) -> Optional[int]: - if v is None: - return v - if v < 10 or v > 3600: - raise ValueError("timeout_seconds must be between 10 and 3600") - return v - - @field_validator("policy") - @classmethod - def validate_policy(cls, v: str) -> str: - if v != "best-effort": - raise ValueError("Only 'best-effort' policy is supported") - return v - - -class FanOutTaskResponse(BaseModel): - """Result of a single fan-out subtask.""" - id: str - status: str - response: Optional[str] = None - error: Optional[str] = None - error_code: Optional[str] = None - execution_id: Optional[str] = None - cost: Optional[float] = None - context_used: Optional[int] = None - duration_ms: Optional[int] = None - - -class FanOutResponse(BaseModel): - """Aggregated fan-out result.""" - fan_out_id: str - status: str - total: int - completed: int - failed: int - results: List[FanOutTaskResponse] - - # --------------------------------------------------------------------------- # Endpoint # --------------------------------------------------------------------------- diff --git a/src/backend/routers/git.py b/src/backend/routers/git.py index 167811440..37ac63059 100644 --- a/src/backend/routers/git.py +++ b/src/backend/routers/git.py @@ -8,11 +8,18 @@ - Pulling from GitHub """ import logging -from typing import Dict, Optional, List +from typing import Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel -from models import User +from models import ( + AutoSyncToggle, + FreezeSchedulesToggle, + GitHubPATRequest, + GitInitializeRequest, + GitPullRequest, + GitSyncRequest, + User, +) from database import db from dependencies import get_current_user, AuthorizedAgentByName, OwnedAgentByName from services import git_service @@ -23,27 +30,6 @@ router = APIRouter(prefix="/api/agents", tags=["git"]) -class GitSyncRequest(BaseModel): - """Request body for git sync operation.""" - message: Optional[str] = None # Custom commit message - paths: Optional[List[str]] = None # Specific paths to sync - strategy: Optional[str] = "normal" # "normal", "pull_first", "force_push" - - -class GitPullRequest(BaseModel): - """Request body for git pull operation.""" - strategy: Optional[str] = "clean" # "clean", "stash_reapply", "force_reset" - - -class GitInitializeRequest(BaseModel): - """Request body for git initialization.""" - repo_owner: str # GitHub username or organization - repo_name: str # Repository name - create_repo: bool = True # Whether to create the repository if it doesn't exist - private: bool = True # Whether the new repository should be private - description: Optional[str] = None # Repository description - - @router.get("/{agent_name}/git/status") async def get_git_status( agent_name: AuthorizedAgentByName, @@ -506,11 +492,6 @@ def get_github_pat_for_agent(agent_name: str) -> str: return get_github_pat() -class GitHubPATRequest(BaseModel): - """Request body for setting agent GitHub PAT.""" - pat: str - - @router.get("/{agent_name}/github-pat") async def get_agent_github_pat_status( agent_name: AuthorizedAgentByName, @@ -694,14 +675,6 @@ async def reset_to_main_preserve_state( # ============================================================================ -class AutoSyncToggle(BaseModel): - enabled: bool - - -class FreezeSchedulesToggle(BaseModel): - enabled: bool - - @router.get("/{agent_name}/git/auto-sync") async def get_auto_sync_config(agent_name: AuthorizedAgentByName): """Return current auto-sync flag and interval for this agent (#389).""" diff --git a/src/backend/routers/image_generation.py b/src/backend/routers/image_generation.py index 517edaead..9f1794cfe 100644 --- a/src/backend/routers/image_generation.py +++ b/src/backend/routers/image_generation.py @@ -6,14 +6,12 @@ import base64 import logging -from typing import Optional from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -from pydantic import BaseModel from dependencies import get_current_user -from models import User +from models import ImageGenerateRequest, User from config import GEMINI_TEXT_MODEL from services.image_generation_service import ( get_image_generation_service, @@ -25,13 +23,6 @@ logger = logging.getLogger(__name__) -class ImageGenerateRequest(BaseModel): - prompt: str - use_case: Optional[str] = "general" - aspect_ratio: Optional[str] = "1:1" - refine_prompt: Optional[bool] = True - - @router.post("/generate") async def generate_image( request: ImageGenerateRequest, diff --git a/src/backend/routers/internal.py b/src/backend/routers/internal.py index eeea79247..250d6fe98 100644 --- a/src/backend/routers/internal.py +++ b/src/backend/routers/internal.py @@ -12,12 +12,21 @@ import os import hmac from fastapi import APIRouter, HTTPException, Depends, Request, Header -from pydantic import BaseModel -from typing import Optional, Dict, List +from typing import Optional, Dict import logging from database import db -from models import ActivityState, ActivityType, ShareFileRequest, ShareFileResponse, TaskExecutionStatus +from models import ( + ActivityCompleteRequest, + ActivityTrackRequest, + ActivityType, + InternalAuditRequest, + InternalTaskExecutionRequest, + ShareFileRequest, + ShareFileResponse, + TaskExecutionStatus, + ValidateExecutionRequest, +) from services.activity_service import activity_service from services.task_execution_service import get_task_execution_service from services.platform_audit_service import platform_audit_service, AuditEventType @@ -110,22 +119,6 @@ async def internal_agent_sync_health(agent_name: str): # Activity Tracking Endpoints (for dedicated scheduler) # ============================================================================= -class ActivityTrackRequest(BaseModel): - """Request model for tracking activity start.""" - agent_name: str - activity_type: str # e.g., "schedule_start" - user_id: Optional[int] = None - triggered_by: str = "schedule" # schedule, manual, user, agent, system - related_execution_id: Optional[str] = None - details: Optional[Dict] = None - - -class ActivityCompleteRequest(BaseModel): - """Request model for completing an activity.""" - status: str = ActivityState.COMPLETED # ActivityState: completed, failed - details: Optional[Dict] = None - error: Optional[str] = None - @router.post("/activities/track") async def track_activity(request: ActivityTrackRequest): @@ -220,22 +213,6 @@ async def complete_activity(activity_id: str, request: ActivityCompleteRequest): # Task Execution Endpoint (for dedicated scheduler) # ============================================================================= -class InternalTaskExecutionRequest(BaseModel): - """Request model for internal task execution via TaskExecutionService.""" - agent_name: str - message: str - triggered_by: str = "schedule" - model: Optional[str] = None - timeout_seconds: Optional[int] = None # TIMEOUT-001: None = use agent's config (default 15 min) - allowed_tools: Optional[List[str]] = None - execution_id: Optional[str] = None - async_mode: bool = False - # #171: optional schedule metadata surfaced in the agent's execution context block. - schedule_name: Optional[str] = None - schedule_cron: Optional[str] = None - schedule_next_run: Optional[str] = None - attempt: Optional[int] = None - def _schedule_context_from(request: "InternalTaskExecutionRequest") -> Optional[Dict]: """Build the schedule_context dict passed to TaskExecutionService, or None.""" @@ -455,16 +432,6 @@ async def _execute_task_internal_background(task_service, request: InternalTaskE # Validation Endpoints (VALIDATE-001) # ============================================================================= -class ValidateExecutionRequest(BaseModel): - """Request model for triggering execution validation.""" - execution_id: str - agent_name: str - schedule_id: str - original_message: str - execution_response: str - custom_prompt: Optional[str] = None - timeout_seconds: int = 120 - @router.post("/validate-execution") async def validate_execution(request: ValidateExecutionRequest): @@ -542,22 +509,6 @@ async def _run_validation_background( # Audit Logging Endpoint (SEC-001 Phase 3 — MCP server integration) # ============================================================================= -class InternalAuditRequest(BaseModel): - """Request model for audit log entries from MCP server.""" - event_type: str # AuditEventType value - event_action: str # e.g. "tool_call" - source: str = "mcp" # Always "mcp" for MCP server calls - # MCP auth context - mcp_key_id: Optional[str] = None - mcp_key_name: Optional[str] = None - mcp_scope: Optional[str] = None - actor_agent_name: Optional[str] = None - # Target - target_type: Optional[str] = None - target_id: Optional[str] = None - # Details - details: Optional[Dict] = None - @router.post("/audit") async def log_audit_entry(request: InternalAuditRequest): diff --git a/src/backend/routers/logs.py b/src/backend/routers/logs.py index 588da50e7..31cfcb0db 100644 --- a/src/backend/routers/logs.py +++ b/src/backend/routers/logs.py @@ -5,10 +5,8 @@ and manual archival operations. """ from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field -from typing import Optional -from models import User +from models import ArchiveRequest, RetentionConfig, User from dependencies import get_current_user from services.log_archive_service import log_archive_service, LOG_RETENTION_DAYS import logging @@ -17,21 +15,6 @@ router = APIRouter(prefix="/api/logs", tags=["logs"]) -# Request/Response Models - -class RetentionConfig(BaseModel): - """Retention configuration.""" - retention_days: int = Field(..., ge=1, le=3650, description="Days to retain logs") - archive_enabled: bool = Field(..., description="Whether archival is enabled") - cleanup_hour: int = Field(..., ge=0, le=23, description="Hour (UTC) to run nightly archival") - - -class ArchiveRequest(BaseModel): - """Manual archive request.""" - retention_days: Optional[int] = Field(None, ge=1, le=3650, description="Override retention days") - delete_after_archive: bool = Field(True, description="Delete originals after archiving") - - # Dependencies def require_admin(current_user: User = Depends(get_current_user)): @@ -186,4 +169,3 @@ async def log_service_health(current_user: User = Depends(require_admin)): "retention_days": int(os.getenv("LOG_RETENTION_DAYS", "90")), "cleanup_hour": int(os.getenv("LOG_CLEANUP_HOUR", "3")), } - diff --git a/src/backend/routers/loops.py b/src/backend/routers/loops.py index 86f14cf42..e181d7a78 100644 --- a/src/backend/routers/loops.py +++ b/src/backend/routers/loops.py @@ -11,11 +11,17 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Header -from pydantic import BaseModel, Field, field_validator from database import db from dependencies import get_authorized_agent, get_current_user -from models import User +from models import ( + LoopRunResponse, + LoopStatusResponse, + StartLoopRequest, + StartLoopResponse, + StopLoopResponse, + User, +) from services.loop_service import get_loop_service logger = logging.getLogger(__name__) @@ -27,74 +33,6 @@ loop_router = APIRouter(prefix="/api/loops", tags=["loops"]) -# --------------------------------------------------------------------------- -# Models -# --------------------------------------------------------------------------- - -MAX_RUNS_LIMIT = 100 -MAX_MESSAGE_LEN = 100_000 -MAX_DELAY_SECONDS = 3600 -MAX_TIMEOUT_PER_RUN = 7200 -MAX_STOP_SIGNAL_LEN = 200 - - -class StartLoopRequest(BaseModel): - message: str = Field(..., min_length=1, max_length=MAX_MESSAGE_LEN) - max_runs: int = Field(..., ge=1, le=MAX_RUNS_LIMIT) - stop_signal: Optional[str] = Field(default=None, max_length=MAX_STOP_SIGNAL_LEN) - delay_seconds: int = Field(default=0, ge=0, le=MAX_DELAY_SECONDS) - timeout_per_run: Optional[int] = Field(default=None, ge=10, le=MAX_TIMEOUT_PER_RUN) - model: Optional[str] = None - allowed_tools: Optional[List[str]] = None - - @field_validator("stop_signal") - @classmethod - def _normalize_stop_signal(cls, v: Optional[str]) -> Optional[str]: - if v is None: - return None - v = v.strip() - return v or None # empty after strip → fixed mode - - -class StartLoopResponse(BaseModel): - loop_id: str - status: str - agent_name: str - max_runs: int - - -class LoopRunResponse(BaseModel): - run_number: int - execution_id: Optional[str] = None - status: str - response_preview: Optional[str] = None - cost: Optional[float] = None - duration_ms: Optional[int] = None - error: Optional[str] = None - started_at: str - completed_at: Optional[str] = None - - -class LoopStatusResponse(BaseModel): - loop_id: str - agent_name: str - status: str - max_runs: int - runs_completed: int - stop_reason: Optional[str] = None - last_response: Optional[str] = None - error: Optional[str] = None - runs: List[LoopRunResponse] - created_at: str - started_at: Optional[str] = None - completed_at: Optional[str] = None - - -class StopLoopResponse(BaseModel): - loop_id: str - status: str # "stopping" | "already_done" - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/src/backend/routers/messages.py b/src/backend/routers/messages.py index b6ed049e9..4e3ad94fd 100644 --- a/src/backend/routers/messages.py +++ b/src/backend/routers/messages.py @@ -6,10 +6,14 @@ """ import logging -from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, EmailStr, Field +from models import ( + ProactiveShareUpdate, + ProactiveSharesResponse, + SendMessageRequest, + SendMessageResponse, +) from database import db from dependencies import get_current_user, AuthorizedAgentByName @@ -27,53 +31,6 @@ router = APIRouter(prefix="/api/agents", tags=["messages"]) -# ============================================================================= -# Request/Response Models -# ============================================================================= - - -class SendMessageRequest(BaseModel): - """Request to send a proactive message to a user.""" - recipient_email: EmailStr = Field( - ..., - description="Verified email of the recipient. Must be in agent_sharing with allow_proactive=1." - ) - text: str = Field( - ..., - min_length=1, - max_length=4096, - description="Message content (max 4096 characters)" - ) - channel: Literal["auto", "telegram", "slack", "web"] = Field( - default="auto", - description="Target channel. 'auto' tries channels in order: telegram -> slack -> web" - ) - reply_to_thread: bool = Field( - default=False, - description="Continue in last thread if one exists (channel-dependent)" - ) - - -class SendMessageResponse(BaseModel): - """Response from sending a proactive message.""" - success: bool - channel: str - message_id: Optional[str] = None - error: Optional[str] = None - - -class ProactiveShareUpdate(BaseModel): - """Request to update allow_proactive flag for a share.""" - email: EmailStr - allow_proactive: bool - - -class ProactiveSharesResponse(BaseModel): - """List of emails with proactive messaging enabled.""" - agent_name: str - emails: list[str] - - # ============================================================================= # Endpoints # ============================================================================= diff --git a/src/backend/routers/notifications.py b/src/backend/routers/notifications.py index 605fd05e4..80d1f1cc2 100644 --- a/src/backend/routers/notifications.py +++ b/src/backend/routers/notifications.py @@ -8,7 +8,7 @@ import json from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel +from models import DismissAllRequest from database import db from dependencies import get_current_user, AuthorizedAgent @@ -23,11 +23,6 @@ ) -class DismissAllRequest(BaseModel): - """Body for bulk-dismissing notifications (#1017).""" - agent_name: Optional[str] = None - - router = APIRouter(prefix="/api", tags=["notifications"]) # WebSocket manager for broadcasting notifications diff --git a/src/backend/routers/operator_queue.py b/src/backend/routers/operator_queue.py index 9adb58037..fabca5c7a 100644 --- a/src/backend/routers/operator_queue.py +++ b/src/backend/routers/operator_queue.py @@ -7,9 +7,9 @@ """ import json -from typing import List, Optional, Set +from typing import Optional, Set from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel, Field +from models import BulkCancelRequest, ClearResolvedRequest, OperatorResponse from database import db from dependencies import get_current_user @@ -29,30 +29,6 @@ def set_websocket_manager(manager): _websocket_manager = manager -# ============================================================================ -# Request/Response Models -# ============================================================================ - -class OperatorResponse(BaseModel): - """Body for responding to a queue item.""" - response: str - response_text: Optional[str] = None - - -class BulkCancelRequest(BaseModel): - """Body for bulk-cancelling pending queue items (#1017). - - The client sends the ids it actually rendered, so a sync-loop race can - never cancel items the operator never saw. - """ - ids: List[str] = Field(..., min_length=1, max_length=500) - - -class ClearResolvedRequest(BaseModel): - """Body for clearing the Resolved tab (#1017).""" - agent_name: Optional[str] = None - - # ============================================================================ # Access control helper # ============================================================================ diff --git a/src/backend/routers/paid.py b/src/backend/routers/paid.py index bc03c13be..9ad72f504 100644 --- a/src/backend/routers/paid.py +++ b/src/backend/routers/paid.py @@ -8,11 +8,10 @@ import base64 import json import logging -from typing import Optional from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel +from models import PaidChatRequest from database import db from services.nevermined_payment_service import ( @@ -25,11 +24,6 @@ logger = logging.getLogger(__name__) -class PaidChatRequest(BaseModel): - message: str - session_id: Optional[str] = None - - @router.get("/{agent_name}/info") async def get_paid_agent_info(agent_name: str): """Get agent payment info and requirements. diff --git a/src/backend/routers/public.py b/src/backend/routers/public.py index 71df632de..fe62841a8 100644 --- a/src/backend/routers/public.py +++ b/src/backend/routers/public.py @@ -11,10 +11,8 @@ import secrets import httpx import logging -from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse -from pydantic import BaseModel from database import ( db, @@ -27,7 +25,7 @@ PublicChatMessage ) from dependencies import get_current_user -from models import User +from models import ClearSessionResponse, PublicChatHistoryResponse, User from routers.auth import check_login_rate_limit, record_login_attempt, get_redis_client from services.agent_auth import agent_httpx_client from services.docker_service import get_agent_container @@ -40,20 +38,6 @@ from services.upload_service import process_file_uploads, decode_web_file, WEB_MAX_FILES, WEB_MAX_FILE_SIZE, WEB_MAX_IMAGE_SIZE, WEB_MAX_TOTAL_IMAGE_SIZE -class PublicChatHistoryResponse(BaseModel): - """Response model for chat history endpoint.""" - messages: List[dict] - session_id: str - message_count: int - - -class ClearSessionResponse(BaseModel): - """Response model for clear session endpoint.""" - cleared: bool - new_session_id: Optional[str] = None - - - logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/public", tags=["public"]) diff --git a/src/backend/routers/public_memory.py b/src/backend/routers/public_memory.py index b3cd3a0a9..bb4252624 100644 --- a/src/backend/routers/public_memory.py +++ b/src/backend/routers/public_memory.py @@ -16,7 +16,7 @@ import re from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from models import WriteUserMemoryRequest from database import db from dependencies import get_current_user @@ -33,11 +33,6 @@ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") -class WriteUserMemoryRequest(BaseModel): - execution_id: str = Field(..., min_length=1, max_length=200) - memory_text: str = Field(..., max_length=8000) - - @router.post("/{agent_name}/user-memory") async def write_user_memory( agent_name: str, diff --git a/src/backend/routers/schedules.py b/src/backend/routers/schedules.py index b3c19b714..e6a0587e8 100644 --- a/src/backend/routers/schedules.py +++ b/src/backend/routers/schedules.py @@ -9,15 +9,23 @@ """ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from typing import List, Optional -from pydantic import BaseModel +from typing import List from datetime import datetime import json import os import logging import httpx -from models import User, ScheduleAnalyticsResponse, AgentSchedulesSummaryResponse +from models import ( + AgentSchedulesSummaryResponse, + ExecutionResponse, + ExecutionSummary, + ScheduleAnalyticsResponse, + ScheduleResponse, + ScheduleUpdateRequest, + User, + WebhookStatusResponse, +) from dependencies import get_current_user, get_authorized_agent, AuthorizedAgent, CurrentUser from database import db, Schedule, ScheduleCreate, ScheduleExecution from services.platform_audit_service import platform_audit_service, AuditEventType @@ -77,148 +85,6 @@ async def get_scheduler_status( return {"running": False, "error": "Scheduler timeout"} -# Request/Response Models - -class ScheduleUpdateRequest(BaseModel): - """Request model for updating a schedule.""" - name: Optional[str] = None - cron_expression: Optional[str] = None - message: Optional[str] = None - enabled: Optional[bool] = None - timezone: Optional[str] = None - description: Optional[str] = None - timeout_seconds: Optional[int] = None - allowed_tools: Optional[List[str]] = None - model: Optional[str] = None # Model override (MODEL-001) - # Retry configuration (RETRY-001) - max_retries: Optional[int] = None - retry_delay_seconds: Optional[int] = None - # Validation configuration (VALIDATE-001) - validation_enabled: Optional[bool] = None - validation_prompt: Optional[str] = None - validation_timeout_seconds: Optional[int] = None - - -class ScheduleResponse(BaseModel): - """Response model for schedule data.""" - id: str - agent_name: str - name: str - cron_expression: str - message: str - enabled: bool - timezone: str - description: Optional[str] - created_at: datetime - updated_at: datetime - last_run_at: Optional[datetime] - next_run_at: Optional[datetime] - # #913: null means "inherit from agent_ownership.execution_timeout_seconds". - timeout_seconds: Optional[int] = None - allowed_tools: Optional[List[str]] = None - model: Optional[str] = None # Model override (MODEL-001) - # Validation configuration (VALIDATE-001) - validation_enabled: bool = False - validation_prompt: Optional[str] = None - validation_timeout_seconds: int = 120 - - class Config: - from_attributes = True - - -class ExecutionSummary(BaseModel): - """Lightweight execution response for list views - excludes large text fields. - - Used by GET /api/agents/{name}/executions for fast list loading. - Full details available via GET /api/agents/{name}/executions/{id}. - """ - id: str - schedule_id: str - agent_name: str - status: str - started_at: datetime - completed_at: Optional[datetime] - duration_ms: Optional[int] - message: str - triggered_by: str - # Observability fields (small) - context_used: Optional[int] = None - context_max: Optional[int] = None - cost: Optional[float] = None - # Origin tracking (small) - AUDIT-001 - source_user_id: Optional[int] = None - source_user_email: Optional[str] = None - source_agent_name: Optional[str] = None - source_mcp_key_id: Optional[str] = None - source_mcp_key_name: Optional[str] = None - # Session resume (small) - EXEC-023 - claude_session_id: Optional[str] = None - # Model selection (small) - MODEL-001 - model_used: Optional[str] = None - # Fan-out linkage (small) - FANOUT-001 - fan_out_id: Optional[str] = None - # Validation tracking (small) - VALIDATE-001 - business_status: Optional[str] = None # pending_validation, validated, failed_validation, skipped - validation_execution_id: Optional[str] = None - # Auto-compact observability (Bundle B) - small JSON list - compact_metadata: Optional[str] = None - - # EXCLUDED (large fields - fetch via /executions/{id}): - # - response: Optional[str] # Full response text - # - error: Optional[str] # Full error text - # - tool_calls: Optional[str] # JSON array of tool calls - # - execution_log: Optional[str] # Full Claude Code transcript - - class Config: - from_attributes = True - - -class ExecutionResponse(BaseModel): - """Full response model for execution data - includes all fields. - - Used by GET /api/agents/{name}/executions/{id} for single execution details. - """ - id: str - schedule_id: str - agent_name: str - status: str - started_at: datetime - completed_at: Optional[datetime] - duration_ms: Optional[int] - message: str - response: Optional[str] - error: Optional[str] - triggered_by: str - # Observability fields - context_used: Optional[int] = None - context_max: Optional[int] = None - cost: Optional[float] = None - tool_calls: Optional[str] = None - execution_log: Optional[str] = None # Full Claude Code execution transcript (JSON) - # Origin tracking - AUDIT-001 - source_user_id: Optional[int] = None - source_user_email: Optional[str] = None - source_agent_name: Optional[str] = None - source_mcp_key_id: Optional[str] = None - source_mcp_key_name: Optional[str] = None - # Session resume - EXEC-023 - claude_session_id: Optional[str] = None - # Model selection - MODEL-001 - model_used: Optional[str] = None - # Fan-out linkage - FANOUT-001 - fan_out_id: Optional[str] = None - # Validation tracking - VALIDATE-001 - business_status: Optional[str] = None - validated_at: Optional[datetime] = None - validation_execution_id: Optional[str] = None - validates_execution_id: Optional[str] = None - # Auto-compact observability (Bundle B) - compact_metadata: Optional[str] = None - - class Config: - from_attributes = True - - # Schedule CRUD Endpoints @@ -591,13 +457,6 @@ async def trigger_schedule( # Webhook Management Endpoints (WEBHOOK-001, #291) -class WebhookStatusResponse(BaseModel): - """Webhook configuration for a schedule.""" - schedule_id: str - has_token: bool - webhook_enabled: bool - webhook_url: Optional[str] = None - def _build_webhook_url(request_base_url: str, token: str) -> str: """Construct the full webhook URL from base URL and token.""" diff --git a/src/backend/routers/sessions.py b/src/backend/routers/sessions.py index cb18eccbe..a7305cc92 100644 --- a/src/backend/routers/sessions.py +++ b/src/backend/routers/sessions.py @@ -20,12 +20,11 @@ from typing import Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query -from pydantic import BaseModel, Field from database import db from db_models import WebFileUpload, SessionMessageInsert from dependencies import AuthorizedAgent, get_current_user -from models import User +from models import CreateSessionRequest, SessionMessageRequest, User from services.docker_service import get_agent_container, get_agent_status_from_container from services.session_cleanup_service import get_session_cleanup_service from services.settings_service import is_session_tab_enabled @@ -45,31 +44,6 @@ router = APIRouter(prefix="/api/agents", tags=["sessions"]) -# --------------------------------------------------------------------------- -# Request / response models -# --------------------------------------------------------------------------- - - -class CreateSessionRequest(BaseModel): - """Optional body for POST /session. All fields optional.""" - - subscription_id: Optional[str] = None - - -class SessionMessageRequest(BaseModel): - """Body for the turn endpoint.""" - - message: str = Field(..., min_length=1) - model: Optional[str] = None - timeout_seconds: Optional[int] = None - # File attachments — same shape as ParallelTaskRequest.files (#364). - # Images become vision blocks for the model; non-images are written - # into the agent workspace and a "[File uploaded by X]: name (size) - # saved to path" line is appended to the prompt so the agent can - # `Read` them. (Phase 5.2 file-upload parity with Chat.) - files: Optional[list] = None - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/src/backend/routers/settings.py b/src/backend/routers/settings.py index 912875829..011a26fe3 100644 --- a/src/backend/routers/settings.py +++ b/src/backend/routers/settings.py @@ -8,14 +8,25 @@ import os import re import httpx -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel logger = logging.getLogger(__name__) -from models import User, AgentDefaultResourcesUpdate, AgentDefaultAccessPolicyUpdate +from models import ( + AgentDefaultAccessPolicyUpdate, + AgentDefaultResourcesUpdate, + AgentQuotaUpdate, + ApiKeyTest, + ApiKeyUpdate, + GitHubTemplatesUpdate, + McpUrlUpdate, + OpsSettingsUpdate, + SlackConnectRequest, + SlackSettingsUpdate, + User, +) from database import db, SystemSetting, SystemSettingUpdate from dependencies import get_current_user from services.platform_audit_service import platform_audit_service, AuditEventType @@ -44,18 +55,9 @@ # ============================================================================ -# API Keys Management - Helper Functions and Models +# API Keys Management - Helper Functions # ============================================================================ -class ApiKeyUpdate(BaseModel): - """Request body for updating an API key.""" - api_key: str - - -class ApiKeyTest(BaseModel): - """Request body for testing an API key.""" - api_key: str - # Note: get_anthropic_api_key and get_github_pat are now imported from # services.settings_service for proper architecture (services shouldn't import from routers) @@ -76,11 +78,6 @@ def mask_api_key(key: str) -> str: # services.settings_service for proper architecture -class OpsSettingsUpdate(BaseModel): - """Request body for updating ops settings.""" - settings: Dict[str, str] - - def require_admin(current_user: User): """Verify user is an admin.""" if current_user.role != "admin": @@ -598,13 +595,6 @@ async def get_slack_settings_status( raise HTTPException(status_code=500, detail=f"Failed to get Slack settings: {str(e)}") -class SlackSettingsUpdate(BaseModel): - """Request body for updating Slack settings.""" - client_id: str = None - client_secret: str = None - signing_secret: str = None - - @router.put("/slack") async def update_slack_settings( body: SlackSettingsUpdate, @@ -681,12 +671,6 @@ async def delete_slack_settings( # ============================================================================ -class SlackConnectRequest(BaseModel): - """Request body for connecting Slack transport.""" - app_token: Optional[str] = None # xapp-... for Socket Mode - transport_mode: Optional[str] = None # "socket" or "webhook" - - @router.get("/slack/status") async def get_slack_transport_status( request: Request, @@ -950,17 +934,6 @@ async def remove_email_from_whitelist( # GitHub Templates Configuration (TMPL-001) # ============================================================================ -class GitHubTemplateEntry(BaseModel): - """A single GitHub template entry.""" - github_repo: str - display_name: str = "" - description: str = "" - - -class GitHubTemplatesUpdate(BaseModel): - """Request body for updating GitHub templates.""" - templates: List[GitHubTemplateEntry] - _REPO_PATTERN = re.compile(r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$') @@ -1079,11 +1052,6 @@ async def delete_github_templates( MCP_URL_SETTING_KEY = "mcp_external_url" -class McpUrlUpdate(BaseModel): - """Request body for updating the MCP server URL.""" - url: str - - def _get_default_mcp_url(request: Request) -> str: """Compute the auto-detected MCP URL from the request hostname.""" host = request.headers.get("host", "localhost:8080") @@ -1189,12 +1157,6 @@ async def delete_mcp_url( # Agent Quota Settings (QUOTA-001) # ============================================================================ -class AgentQuotaUpdate(BaseModel): - """Request body for updating per-role agent quotas.""" - max_agents_creator: Optional[str] = None - max_agents_operator: Optional[str] = None - max_agents_user: Optional[str] = None - @router.get("/agent-quotas") async def get_agent_quotas( @@ -1668,5 +1630,3 @@ async def reset_ops_settings( # Note: get_ops_setting is now imported from services.settings_service - - diff --git a/src/backend/routers/setup.py b/src/backend/routers/setup.py index 48a490f1d..8e39d70cc 100644 --- a/src/backend/routers/setup.py +++ b/src/backend/routers/setup.py @@ -18,11 +18,10 @@ """ import logging import re -from typing import Optional from fastapi import APIRouter, BackgroundTasks, HTTPException, Request -from pydantic import BaseModel, Field +from models import SetAdminPasswordRequest from database import db from dependencies import hash_password from services.operator_intake_service import submit_operator_intake @@ -42,30 +41,6 @@ router = APIRouter(prefix="/api/setup", tags=["setup"]) -class SetAdminPasswordRequest(BaseModel): - """Request body for creating the admin account at first-time setup. - - `email` is **required** (trinity-enterprise#49): it becomes the admin's - sign-in identity (login with email + password instead of the fixed 'admin') - and is the contact used for the optional operator intake. The remaining - operator-profile fields (company/name/role/use_case) stay optional and are - only forwarded to the hosted intake endpoint when `consent_updates` is true. - """ - password: str = Field(..., max_length=128) - confirm_password: str = Field(..., max_length=128) - # Required admin email — sign-in identity. Shape validated in the handler so - # a typo / blank value yields a clean 400 (a missing field yields a 422). - email: str = Field(..., max_length=254) - # Optional operator profile — all skippable; setup completes without them. - company: Optional[str] = Field(None, max_length=200) - name: Optional[str] = Field(None, max_length=200) - role: Optional[str] = Field(None, max_length=200) - use_case: Optional[str] = Field(None, max_length=500) - # Affirmative, opt-in consent to occasionally receive security & product - # updates. ONLY when true is anything submitted to the hosted intake. - consent_updates: bool = False - - @router.get("/status") async def get_setup_status(): """ diff --git a/src/backend/routers/sharing.py b/src/backend/routers/sharing.py index f3908d3f4..b0c302b3b 100644 --- a/src/backend/routers/sharing.py +++ b/src/backend/routers/sharing.py @@ -7,9 +7,8 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel -from models import User +from models import AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision from database import db, AgentShare, AgentShareRequest from dependencies import get_current_user, OwnedAgentByName, CurrentUser from services.docker_service import get_agent_container @@ -65,34 +64,6 @@ async def _notify_access_request_approval( router = APIRouter(prefix="/api/agents", tags=["sharing"]) -# --------------------------------------------------------------------------- -# Models for unified access control (Issue #311) -# --------------------------------------------------------------------------- - -class AccessPolicy(BaseModel): - require_email: bool - open_access: bool - group_auth_mode: str = "none" # 'none' or 'any_verified' - - -class AccessPolicyUpdate(BaseModel): - require_email: bool - open_access: bool - group_auth_mode: str = "none" # 'none' or 'any_verified' - - -class AccessRequest(BaseModel): - id: str - agent_name: str - email: str - channel: str | None = None - requested_at: str - status: str - - -class AccessRequestDecision(BaseModel): - approve: bool - # WebSocket manager will be injected from main.py manager = None diff --git a/src/backend/routers/slack.py b/src/backend/routers/slack.py index 4132c8b78..3f69dd8cf 100644 --- a/src/backend/routers/slack.py +++ b/src/backend/routers/slack.py @@ -15,14 +15,12 @@ """ import logging -from typing import Optional from fastapi import APIRouter, HTTPException, Request, Depends from fastapi.responses import RedirectResponse -from pydantic import BaseModel from database import db from dependencies import get_current_user -from models import User +from models import SlackEventResponse, User from services.slack_service import slack_service from db_models import SlackConnectionStatus, SlackOAuthInitResponse from services.settings_service import get_slack_signing_secret @@ -51,12 +49,6 @@ def set_webhook_transport(transport): public_router = APIRouter(prefix="/api/public/slack", tags=["slack-public"]) -class SlackEventResponse(BaseModel): - """Response to Slack events (always return 200).""" - ok: bool = True - challenge: Optional[str] = None - - @public_router.post("/events", response_model=SlackEventResponse) async def handle_slack_event(request: Request): """ diff --git a/src/backend/routers/telegram.py b/src/backend/routers/telegram.py index 9081e2363..24bd527ed 100644 --- a/src/backend/routers/telegram.py +++ b/src/backend/routers/telegram.py @@ -21,11 +21,19 @@ import httpx from fastapi import APIRouter, HTTPException, Request, Depends -from pydantic import BaseModel from database import db from dependencies import get_current_user, OwnedAgentByName -from models import User +from models import ( + TelegramBindingResponse, + TelegramConfigureRequest, + TelegramGroupConfigResponse, + TelegramGroupConfigUpdateRequest, + TelegramGroupMessageRequest, + TelegramTestRequest, + TelegramWebhookResponse, + User, +) logger = logging.getLogger(__name__) @@ -50,10 +58,6 @@ def set_webhook_transport(transport): public_router = APIRouter(prefix="/api/telegram", tags=["telegram-public"]) -class TelegramWebhookResponse(BaseModel): - ok: bool = True - - @public_router.post("/webhook/{webhook_secret}", response_model=TelegramWebhookResponse) async def handle_telegram_webhook(webhook_secret: str, request: Request): """ @@ -77,48 +81,6 @@ async def handle_telegram_webhook(webhook_secret: str, request: Request): auth_router = APIRouter(prefix="/api/agents", tags=["telegram"]) -class TelegramBindingResponse(BaseModel): - agent_name: str - bot_username: Optional[str] = None - bot_id: Optional[str] = None - webhook_url: Optional[str] = None - bot_link: Optional[str] = None - configured: bool = False - group_count: int = 0 - warning: Optional[str] = None - - -class TelegramConfigureRequest(BaseModel): - bot_token: str - - -class TelegramTestRequest(BaseModel): - chat_id: Optional[str] = None - message: str = "Hello from Trinity! Your Telegram bot is configured correctly." - - -class TelegramGroupConfigResponse(BaseModel): - id: int - chat_id: str - chat_title: Optional[str] = None - chat_type: str = "group" - trigger_mode: str = "mention" - welcome_enabled: bool = False - welcome_text: Optional[str] = None - is_active: bool = True - - -class TelegramGroupConfigUpdateRequest(BaseModel): - trigger_mode: Optional[str] = None - welcome_enabled: Optional[bool] = None - welcome_text: Optional[str] = None - - -class TelegramGroupMessageRequest(BaseModel): - """Request model for proactive group messaging (Issue #349).""" - message: str - - @auth_router.get("/{agent_name}/telegram", response_model=TelegramBindingResponse) async def get_telegram_binding( agent_name: str, diff --git a/src/backend/routers/users.py b/src/backend/routers/users.py index 3bceb9fbb..d0d9d0b44 100644 --- a/src/backend/routers/users.py +++ b/src/backend/routers/users.py @@ -6,9 +6,8 @@ import re from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from models import User +from models import User, UserRoleUpdate, UpdateMyEmailRequest from database import db from dependencies import require_admin, get_current_user @@ -21,14 +20,6 @@ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s.]+$") -class UserRoleUpdate(BaseModel): - role: str - - -class UpdateMyEmailRequest(BaseModel): - email: str - - @router.put("/me/email") async def update_my_email( body: UpdateMyEmailRequest, diff --git a/src/backend/routers/voice.py b/src/backend/routers/voice.py index 3b35a0aaa..4f963b1bd 100644 --- a/src/backend/routers/voice.py +++ b/src/backend/routers/voice.py @@ -13,12 +13,10 @@ import json import logging import types -from typing import Optional from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query -from pydantic import BaseModel -from models import User +from models import User, VoiceStartRequest, VoiceStartResponse, VoiceStopRequest, VoiceStopResponse from dependencies import get_current_user, get_authorized_agent from database import db from config import GEMINI_API_KEY, VOICE_ENABLED @@ -32,30 +30,6 @@ router = APIRouter(tags=["voice"]) -# ── Request/Response Models ────────────────────────────────────────────────── - -class VoiceStartRequest(BaseModel): - session_id: Optional[str] = None # Existing chat session to continue - voice_name: Optional[str] = None # Gemini voice name (e.g. "Kore", "Puck") - workspace_mode: bool = False # Enable canvas panel tools - - -class VoiceStartResponse(BaseModel): - voice_session_id: str - websocket_url: str - chat_session_id: str - - -class VoiceStopRequest(BaseModel): - voice_session_id: str - - -class VoiceStopResponse(BaseModel): - transcript: list - messages_saved: int - duration_seconds: float - - # ── REST Endpoints ─────────────────────────────────────────────────────────── @router.post("/api/agents/{name}/voice/start", response_model=VoiceStartResponse) diff --git a/src/backend/routers/voip.py b/src/backend/routers/voip.py index af71c15f9..7ffa5058d 100644 --- a/src/backend/routers/voip.py +++ b/src/backend/routers/voip.py @@ -24,11 +24,10 @@ Query, status, ) -from pydantic import BaseModel from database import db from dependencies import AuthorizedAgentByName, OwnedAgentByName, get_current_user -from models import User +from models import User, VoipBindingResponse, VoipCallRequest, VoipConfigureRequest from services import idempotency_service from services.settings_service import settings_service from services.voip_service import voip_service, normalize_e164 @@ -40,31 +39,6 @@ public_router = APIRouter(tags=["voip-public"]) -# ── Request/Response models ────────────────────────────────────────────────── - -class VoipConfigureRequest(BaseModel): - account_sid: str - auth_token: str - from_number: str - daily_call_cap: Optional[int] = None - - -class VoipBindingResponse(BaseModel): - agent_name: str - configured: bool - account_sid: Optional[str] = None - from_number: Optional[str] = None - daily_call_cap: Optional[int] = None - display_name: Optional[str] = None - enabled: Optional[bool] = None - - -class VoipCallRequest(BaseModel): - to_number: str - context: Optional[str] = None - process_transcript: bool = True - - # ── Helpers ────────────────────────────────────────────────────────────────── def _require_enabled(): diff --git a/src/backend/routers/webhooks.py b/src/backend/routers/webhooks.py index f22dccbf9..f324ff9fd 100644 --- a/src/backend/routers/webhooks.py +++ b/src/backend/routers/webhooks.py @@ -21,7 +21,7 @@ import httpx from fastapi import APIRouter, Header, HTTPException, Request, status from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from models import CONTEXT_MAX_CHARS, WebhookTriggerRequest from database import db from services.platform_audit_service import platform_audit_service, AuditEventType @@ -39,9 +39,6 @@ WEBHOOK_RATE_LIMIT = int(os.getenv("WEBHOOK_RATE_LIMIT", "10")) WEBHOOK_RATE_WINDOW = 60 # seconds -# Max length for the optional context field -CONTEXT_MAX_CHARS = 4000 - # Webhook tokens are secrets.token_urlsafe(32) — exactly 43 chars (CSO OBS-2). # Tightened from {20,60}: prior regex was a defense-in-depth early-reject; # DB lookup is authoritative either way. @@ -50,19 +47,6 @@ router = APIRouter(prefix="/api/webhooks", tags=["webhooks"]) -class WebhookTriggerRequest(BaseModel): - """Optional body for a webhook trigger call.""" - context: Optional[str] = Field( - default=None, - description="Additional context appended to the schedule message.", - max_length=CONTEXT_MAX_CHARS, - ) - metadata: Optional[dict] = Field( - default=None, - description="Arbitrary key/value metadata stored on the execution record.", - ) - - # --------------------------------------------------------------------------- # Public trigger endpoint # --------------------------------------------------------------------------- diff --git a/src/backend/routers/whatsapp.py b/src/backend/routers/whatsapp.py index 65c1c317c..df4255024 100644 --- a/src/backend/routers/whatsapp.py +++ b/src/backend/routers/whatsapp.py @@ -19,12 +19,10 @@ """ import logging -from typing import Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response -from pydantic import BaseModel from adapters.transports.twilio_webhook import ( backfill_webhook_urls, @@ -32,7 +30,7 @@ ) from database import db from dependencies import OwnedAgentByName, get_current_user -from models import User +from models import User, WhatsAppBindingResponse, WhatsAppConfigureRequest, WhatsAppTestRequest from services.settings_service import settings_service logger = logging.getLogger(__name__) @@ -89,30 +87,6 @@ async def handle_twilio_webhook(webhook_secret: str, request: Request): auth_router = APIRouter(prefix="/api/agents", tags=["whatsapp"]) -class WhatsAppBindingResponse(BaseModel): - agent_name: str - configured: bool = False - account_sid: Optional[str] = None - from_number: Optional[str] = None - messaging_service_sid: Optional[str] = None - display_name: Optional[str] = None - is_sandbox: bool = False - webhook_url: Optional[str] = None - warning: Optional[str] = None - - -class WhatsAppConfigureRequest(BaseModel): - account_sid: str - auth_token: str - from_number: str - messaging_service_sid: Optional[str] = None - - -class WhatsAppTestRequest(BaseModel): - to_number: Optional[str] = None - message: str = "Hello from Trinity! Your WhatsApp integration is configured correctly." - - def _validate_from_number(from_number: str) -> str: """Ensure the from_number is in Twilio's 'whatsapp:+E164' format.""" value = (from_number or "").strip() diff --git a/tests/unit/test_models_centralized.py b/tests/unit/test_models_centralized.py new file mode 100644 index 000000000..a9c989973 --- /dev/null +++ b/tests/unit/test_models_centralized.py @@ -0,0 +1,101 @@ +"""Static guard: Pydantic request/response models live in models.py (#654). + +Architectural Invariant #14 — "Pydantic Models Centralized in ``models.py``" — +governs the **router** layer: a ``class X(BaseModel)`` must not be defined under +``src/backend/routers/``. The API contract stays in one place, so a reviewer can +read every request/response shape without spelunking 32 routers. + +Scope (Codex #5): INV-14 as fingerprinted covers router models only. Two model +homes are **intentionally separate** and out of scope here: + - ``db_models.py`` — DB-row / persistence models (a distinct layer). + - ``adapters/base.py`` — the ChannelAdapter ABC's normalized message models. + +Allowlist: exactly one router model is exempt — ``canary.py::RunCycleRequest`` — +because it evaluates ``INVARIANTS`` (from the ``canary`` library) in a +``Field(description=...)`` at *class-definition time*, and the ``canary`` library +imports ``TaskExecutionStatus`` back from ``models`` (``canary/snapshot.py``). +Relocating it would force ``models.py`` to ``from canary import INVARIANTS`` — +inverting the dependency direction of a module that is meant to be a low-level +leaf everything else imports *from*. Keeping it in the router is the correct +home; this single, documented exception is enforced (not a free pass). + +Lives in tests/unit/ (not tests/) so it runs as a pure static check without the +backend-connection autouse fixtures the integration suite carries. +""" +import ast +from pathlib import Path + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +_ROUTERS = _BACKEND / "routers" + +# (filename, classname) pairs allowed to remain a BaseModel under routers/. +# Keep this MINIMAL and documented — every entry needs a verified reason above. +_ALLOWLIST: set[tuple[str, str]] = { + ("canary.py", "RunCycleRequest"), +} + + +def _is_basemodel(node: ast.ClassDef) -> bool: + for base in node.bases: + if isinstance(base, ast.Name) and base.id == "BaseModel": + return True + if isinstance(base, ast.Attribute) and base.attr == "BaseModel": + return True + return False + + +def _router_basemodels() -> set[tuple[str, str]]: + found: set[tuple[str, str]] = set() + for path in sorted(_ROUTERS.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and _is_basemodel(node): + found.add((path.name, node.name)) + return found + + +def test_no_basemodels_outside_allowlist_in_routers(): + """Routers define no Pydantic models except the documented allowlist.""" + offenders = sorted(_router_basemodels() - _ALLOWLIST) + assert not offenders, ( + "These BaseModel subclasses are defined under src/backend/routers/ but " + "belong in src/backend/models.py (Architectural Invariant #14, #654). " + "Move each class VERBATIM into its domain section in models.py and import " + "it back where the router still references it:\n " + + "\n ".join(f"{f}::{c}" for f, c in offenders) + ) + + +def test_allowlist_entries_still_exist(): + """Guard against a stale allowlist — every exemption must still be real.""" + present = _router_basemodels() + stale = sorted(_ALLOWLIST - present) + assert not stale, ( + "These allowlisted router models no longer exist — drop them from " + "_ALLOWLIST so the guard stays honest:\n " + + "\n ".join(f"{f}::{c}" for f, c in stale) + ) + + +def test_models_py_present(): + """models.py must exist as a flat module (conftest preload + Dockerfile + COPY *.py both depend on it; a package conversion would break both).""" + assert (_BACKEND / "models.py").is_file() + + +def test_guard_detects_planted_router_model(tmp_path): + """The guard must FAIL on a new BaseModel planted in a routers/ file.""" + fake_routers = tmp_path / "routers" + fake_routers.mkdir() + (fake_routers / "planted.py").write_text( + "from pydantic import BaseModel\n\n\nclass PlantedRequest(BaseModel):\n" + " field: str\n", + encoding="utf-8", + ) + found = set() + for path in fake_routers.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and _is_basemodel(node): + found.add((path.name, node.name)) + assert ("planted.py", "PlantedRequest") in found diff --git a/tests/unit/test_router_model_validators.py b/tests/unit/test_router_model_validators.py new file mode 100644 index 000000000..2889acd70 --- /dev/null +++ b/tests/unit/test_router_model_validators.py @@ -0,0 +1,154 @@ +"""Validator-preservation tests for the router models relocated by #654. + +The #654 move was mechanical (cut-paste each class verbatim into models.py), +and the normalized OpenAPI snapshot diff proves the *schema* is byte-identical. +But two behaviour-bearing aspects are INVISIBLE to an OpenAPI diff (Codex #3/#8): + + 1. ``@field_validator`` logic — a dropped/mangled validator changes which + inputs are rejected while the JSON schema stays the same. + 2. ``Config`` / ``Field(default_factory=...)`` — ORM-mode (``from_attributes``) + and per-instance mutable defaults don't surface in the schema. + +These pure-unit tests feed known-bad input (or check the default/config) for the +five behaviour-bearing router files (fan_out, loops, canary, audit_log, +schedules) so a careless re-paste fails loudly here, not silently in prod. + +(The plan named tests/test_fan_out.py, but that file is an *integration* suite +requiring a live backend; validator preservation is a pure model concern, so it +lives here under tests/unit/ where the real models module is preloaded.) +""" +import pytest +from pydantic import ValidationError + +import models + + +# --------------------------------------------------------------------------- +# fan_out — FanOutTask / FanOutRequest validators + moved constants +# --------------------------------------------------------------------------- + +def test_fan_out_constants_moved(): + assert models.MAX_TASKS == 50 + assert models.MAX_CONCURRENCY == 10 + assert models.TASK_ID_RE.match("abc-123_X") is not None + assert models.TASK_ID_RE.match("bad id!") is None + + +def test_fan_out_task_id_validator_rejects_bad_chars(): + with pytest.raises(ValidationError): + models.FanOutTask(id="has spaces!", message="hi") + # good id still constructs + assert models.FanOutTask(id="task_1", message="hi").id == "task_1" + + +def test_fan_out_request_rejects_empty_tasks(): + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=[]) + + +def test_fan_out_request_rejects_too_many_tasks(): + tasks = [{"id": f"t{i}", "message": "x"} for i in range(models.MAX_TASKS + 1)] + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=tasks) + + +def test_fan_out_request_rejects_duplicate_ids(): + with pytest.raises(ValidationError): + models.FanOutRequest( + tasks=[{"id": "dup", "message": "a"}, {"id": "dup", "message": "b"}] + ) + + +def test_fan_out_request_rejects_bad_concurrency_and_policy_and_timeout(): + base = [{"id": "t1", "message": "x"}] + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=base, max_concurrency=0) + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=base, max_concurrency=models.MAX_CONCURRENCY + 1) + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=base, policy="all-or-nothing") + with pytest.raises(ValidationError): + models.FanOutRequest(tasks=base, timeout_seconds=5) # < 10 + # valid request still constructs + ok = models.FanOutRequest(tasks=base, max_concurrency=3, policy="best-effort") + assert ok.max_concurrency == 3 + + +# --------------------------------------------------------------------------- +# loops — StartLoopRequest validator + Field bounds + moved constants +# --------------------------------------------------------------------------- + +def test_loops_constants_moved(): + assert models.MAX_RUNS_LIMIT == 100 + assert models.MAX_MESSAGE_LEN == 100_000 + assert models.MAX_DELAY_SECONDS == 3600 + assert models.MAX_TIMEOUT_PER_RUN == 7200 + assert models.MAX_STOP_SIGNAL_LEN == 200 + + +def test_start_loop_stop_signal_normalizer_preserved(): + # whitespace-only stop_signal collapses to None (fixed mode) + assert models.StartLoopRequest(message="m", max_runs=1, stop_signal=" ").stop_signal is None + # surrounding whitespace is stripped + assert models.StartLoopRequest(message="m", max_runs=1, stop_signal=" done ").stop_signal == "done" + + +def test_start_loop_max_runs_bounds_preserved(): + with pytest.raises(ValidationError): + models.StartLoopRequest(message="m", max_runs=0) # < 1 + with pytest.raises(ValidationError): + models.StartLoopRequest(message="m", max_runs=models.MAX_RUNS_LIMIT + 1) + with pytest.raises(ValidationError): + models.StartLoopRequest(message="", max_runs=1) # empty message + + +# --------------------------------------------------------------------------- +# canary / audit_log — Field(default_factory=dict) preserved (independent dicts) +# --------------------------------------------------------------------------- + +def test_canary_default_factory_dicts_are_independent(): + a = models.CanaryStatsResponse(total=0) + b = models.CanaryStatsResponse(total=0) + assert a.by_invariant == {} and a.by_severity == {} + a.by_invariant["S-01"] = 1 + assert b.by_invariant == {}, "default_factory must give each instance its own dict" + + v = models.CanaryViolation( + id=1, invariant_id="S-01", tier="A", severity="critical", snapshot_time="t" + ) + assert v.observed_state == {} + + +def test_audit_log_stats_default_factory_dicts_are_independent(): + a = models.AuditLogStatsResponse(total=0) + b = models.AuditLogStatsResponse(total=0) + assert a.by_event_type == {} and a.by_actor_type == {} + a.by_event_type["login"] = 3 + assert b.by_event_type == {} + + +# --------------------------------------------------------------------------- +# schedules — Config.from_attributes preserved (ORM mode; invisible to OpenAPI) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "model_name", + ["ScheduleResponse", "ExecutionSummary", "ExecutionResponse"], +) +def test_schedules_from_attributes_config_preserved(model_name): + model = getattr(models, model_name) + assert model.model_config.get("from_attributes") is True, ( + f"{model_name}.Config(from_attributes=True) was lost in the move — " + "ORM-mode construction would silently break (not visible to OpenAPI)" + ) + + +# --------------------------------------------------------------------------- +# webhooks — CONTEXT_MAX_CHARS constant moved + Field bound preserved +# --------------------------------------------------------------------------- + +def test_webhooks_context_max_chars_moved_and_enforced(): + assert models.CONTEXT_MAX_CHARS == 4000 + with pytest.raises(ValidationError): + models.WebhookTriggerRequest(context="x" * (models.CONTEXT_MAX_CHARS + 1)) + assert models.WebhookTriggerRequest(context="ok").context == "ok" diff --git a/tests/unit/test_telegram_webhook_backfill.py b/tests/unit/test_telegram_webhook_backfill.py index 0dd3ad5f0..751883f11 100644 --- a/tests/unit/test_telegram_webhook_backfill.py +++ b/tests/unit/test_telegram_webhook_backfill.py @@ -16,7 +16,7 @@ import sys import types from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -32,6 +32,8 @@ "services", "services.settings_service", "services.platform_audit_service", + "services.agent_service", + "services.agent_service.capabilities", "adapters", "adapters.transports", "adapters.transports.telegram_webhook", @@ -75,9 +77,6 @@ def _stub(name, **attrs): from pydantic import BaseModel - class _User(BaseModel): - username: str = "admin" - class _SystemSetting(BaseModel): key: str value: str = "" @@ -85,19 +84,12 @@ class _SystemSetting(BaseModel): class _SystemSettingUpdate(BaseModel): value: str = "" - class _AgentDefaultResourcesUpdate(BaseModel): - max_parallel_tasks: int | None = None - execution_timeout_seconds: int | None = None - - class _AgentDefaultAccessPolicyUpdate(BaseModel): - require_email: bool | None = None - - _stub( - "models", - User=_User, - AgentDefaultResourcesUpdate=_AgentDefaultResourcesUpdate, - AgentDefaultAccessPolicyUpdate=_AgentDefaultAccessPolicyUpdate, - ) + # Use the REAL models module rather than a partial fake: settings.py imports + # its request/response models from `models` (#654 centralization), so a + # hand-listed stub would need every moved name and break whenever the import + # surface grows. `models` is a safe leaf (imports only utils.helpers + + # db_models) and is already preloaded by tests/conftest.py. + stubs["models"] = importlib.import_module("models") db_stub = MagicMock() db_stub.set_setting = MagicMock(return_value=_SystemSetting(key="public_chat_url")) @@ -136,7 +128,17 @@ class _AgentDefaultAccessPolicyUpdate(BaseModel): AGENT_DEFAULT_REQUIRE_EMAIL=True, get_agent_default_require_email=MagicMock(return_value=True), ) - _stub("services", __path__=[]) + # settings.py module-level imports VALID_CPU/VALID_MEMORY from the + # container-spec module (#1197); stub it so the standalone load completes. + services_mod = _stub("services", __path__=[]) + agent_service_mod = _stub("services.agent_service", __path__=[]) + capabilities_mod = _stub( + "services.agent_service.capabilities", + VALID_CPU=["1", "2", "4", "8", "16"], + VALID_MEMORY=["1g", "2g", "4g", "8g", "16g", "32g"], + ) + services_mod.agent_service = agent_service_mod + agent_service_mod.capabilities = capabilities_mod # Stub adapters.transports.telegram_webhook before load so the function # can import it lazily without hitting the real backend. From eee8db64bb16137f66d5623a83da94b0f61b1a8e Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Tue, 23 Jun 2026 22:49:14 +0100 Subject: [PATCH 2/4] docs(models): add INV-14 feature-flow row + CSO diff report (#654) - feature-flows.md: Recent Updates row for the router-model centralization (Invariant #14), noting behavior-preserved / OpenAPI-byte-identical and the documented canary.RunCycleRequest allowlist exception. - security-reports: CSO --diff report for PR #1308 (37 files); zero findings across all categories. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/memory/feature-flows.md | 1 + .../cso-diff-2026-06-23-inv14-models.md | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 docs/security-reports/cso-diff-2026-06-23-inv14-models.md diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index af0da447c..746d28ea2 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -11,6 +11,7 @@ | Date | ID | Feature | Flow | |------|-----|---------|------| +| 2026-06-23 | #654 | refactor(models): centralize router Pydantic models in `models.py` (**Invariant #14**). ~97 request/response models that had been defined inline across `routers/*.py` were moved into the single `src/backend/models.py` and imported back into their routers — the API contract now lives in one place. **Pure refactor, no behavioral change:** every model's fields/validators/`default_factory` are unchanged and the generated **OpenAPI schema is byte-identical** (proven, not assumed). `models.py` is kept a **flat module** (not a `models/` package) on purpose; `db_models.py` (DB-row/persistence layer) and `adapters/base.py` (`NormalizedMessage`/`ChannelResponse` for the ChannelAdapter ABC) remain **intentional separate homes** and are out of scope. One documented allowlist exception: `routers/canary.py::RunCycleRequest` stays in its router because it evaluates the `canary` library's `INVARIANTS` in a `Field(description=…)` at class-definition time and `canary` imports `TaskExecutionStatus` back from `models` — relocating it would invert that dependency direction. Guarded by a static test `tests/unit/test_models_centralized.py` (fails any new `class X(BaseModel)` defined under `routers/`) plus `test_router_model_validators.py` (validator/default-factory behavioral parity). **Note for flow docs:** illustrative `class XRequest(BaseModel)` snippets in individual flows stay field-accurate — only the physical definition home moved (router → `models.py`); the INV-7/INV-8 router/service-layer cleanups were split out to #1309/#1310. | [architecture.md → Invariant #14](architecture.md) | | 2026-06-22 | trinity-enterprise#38, #82 | feat(setup): first-run operator intake + admin email login. Optional **email + company** at first-run setup with an **unchecked-by-default** opt-in ("occasionally receive important security & product updates"); on consent → one **fire-and-forget, at-most-once** POST to a new `/v1/operator-intake` endpoint on #1116's Cloudflare intake app (`intake.abilityai.dev`, already CSP-allowed — **one added endpoint**, Worker out-of-repo). The same email **binds as the admin's sign-in identity** (log in with email + password) — **no verification email is sent** (a fresh install has no Resend key; bound, not code-verified); the code-based email second factor stays **Phase 2** on the existing `mfa_gate` seam (#5/#388), untouched here. Backend: `services/operator_intake_service.py` (DO_NOT_TRACK/`OPERATOR_INTAKE_ENABLED` off-switch, `operator_intake_submitted` marker claimed before the POST, owns `installation_id` — the #758 seed, never raises/logs the email); `routers/setup.py` captures profile + binds email (validates shape before any write, schedules intake via `BackgroundTasks` so it never delays setup); `dependencies.authenticate_user` resolves admin by username **OR** registered email (password-hash guard keeps email-code-only users off the password path); `PUT /api/users/me/email` (own-account, 409 on collision) for the existing-admin transition. Frontend: `SetupPassword.vue` fields + consent, `Login.vue` "Username or email", `Settings.vue` → General "Admin sign-in email" card. `OPERATOR_INTAKE_ENABLED`/`OPERATOR_INTAKE_URL` in `.env.example`. 16 unit tests. | [first-time-setup.md](feature-flows/first-time-setup.md) | | 2026-06-21 | #1159 | fix(security): authenticate the in-container agent server on the shared agent network — the agent server (`:8000`, FastAPI) historically had **zero auth** on the shared `trinity-agent-network`, so any agent could call a sibling's chat/files/credentials (a now-removed unauthenticated `/ws/chat` route even ran `runtime.execute` — arbitrary Claude). Every backend→agent call now carries a per-agent `X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET, "trinity-agent-auth:v1:"+name)` — **derived, not stored** (no DB column; the master lives only in the backend env, so a compromised agent holds its own token but cannot compute a sibling's). New `services/agent_auth.py` helpers (`derive_agent_token`/`build_agent_auth_headers`/`merge_auth_headers`/`agent_httpx_client`), fail-closed (raises on empty `AGENT_AUTH_SECRET`); `start.sh` auto-generates the hex32 master and both compose files forward it. Enforced by a **pure-ASGI** middleware (`agent_server/middleware/auth.py`, `hmac.compare_digest`) on **all** HTTP **and** WebSocket routes (scope-complete, never buffers SSE), exempting only exact `/health` + `OPTIONS`; WS rejects with close 1008 before `accept()`. Grace path: empty `TRINITY_AGENT_AUTH_TOKEN` → allow (old-image rollout); `check_agent_auth_token_env_matches` forces a one-pass recreate to re-inject a missing/stale (renamed-agent) token. Dead `/ws/chat` route + CORS `*` removed (agent server is internal-only). ~10 backend routers/services migrated through the helpers; static guard `test_agent_auth_header_guard.py` fails any new raw `agent-{name}:8000` caller. | [agent-server-authentication.md](feature-flows/agent-server-authentication.md) | | 2026-06-21 | #946 | feat(orchestration): **pull-pilot routing** for agent→agent MCP chat (Phase 2 PoC for pull/work-stealing, Epic #1045 / umbrella #1081). Behind `MCP_AGENT_CHAT_PULL_ENABLED` (**default OFF**): a **sequential** agent→agent (`scope='agent'`, non-self) `chat_with_agent` is routed by the MCP server through the durable async `/task` path instead of the synchronous held `/chat`; the caller gets an immediate `{status:accepted\|queued, execution_id}` receipt and polls `get_execution_result` (polling is the contract — no completion event). `scope='user'`, self-tasks (SELF-EXEC-001 inject-result/chat-session semantics), and `parallel=true` stay unchanged. `tools/chat.ts` now reads `authContext` unconditionally so scope is decidable (behaviour-equivalent in prod); **D8** route-token (`chat-pull-task`) folds the dispatch route into the idempotency identity so a mid-soak flag flip can't replay a wrong-shape snapshot across `/chat`↔`/task` under the shared `agent:{name}` scope. `config.py` holds the canonical `MCP_AGENT_CHAT_PULL_ENABLED` registry entry (both services read the SAME env key → no single-`.env` drift; actual gate is MCP-side); `settings.py` surfaces `mcp_agent_chat_pull_enabled` in `GET /api/settings/feature-flags` (auth-gated, observability-only, **not** a UI surface); **T5** `chat.py` releases the idempotency claim on the two `/task` dispatch-breaker-open (`CircuitOpen`) deny paths (mirrors `/chat` + CapacityFull — else a breaker-open reject blocks same-key retries 24h). Rollback = flag flip + MCP routing revert. `chat.test.ts` (routing fork + key) + `test_946_task_idempotency_on_deny.py` + feature-flag exposure tests. | [agent-to-agent-collaboration.md](feature-flows/agent-to-agent-collaboration.md), [mcp-orchestration.md](feature-flows/mcp-orchestration.md) | diff --git a/docs/security-reports/cso-diff-2026-06-23-inv14-models.md b/docs/security-reports/cso-diff-2026-06-23-inv14-models.md new file mode 100644 index 000000000..f78a4a71e --- /dev/null +++ b/docs/security-reports/cso-diff-2026-06-23-inv14-models.md @@ -0,0 +1,72 @@ +# CSO Security Audit + +**Mode**: diff +**Scope**: `AndriiPasternak31/autoplan-issue-654` vs `origin/dev` (#654 INV-14 model centralization, PR #1308) +**Date**: 2026-06-23 +**Diff size**: 37 files, +1548 / −1092 + +## Summary + +| Category | CRITICAL | HIGH | MEDIUM | LOW | +|----------|----------|------|--------|-----| +| Secrets | 0 | 0 | 0 | 0 | +| Dependencies | 0 | 0 | 0 | 0 | +| Auth Boundaries | 0 | 0 | 0 | 0 | +| Injection | 0 | 0 | 0 | 0 | +| Platform Patterns | 0 | 0 | 0 | 0 | +| Configuration | 0 | 0 | 0 | 0 | + +## Nature of the change + +Pure refactor enforcing Architectural Invariant #14: ~97 Pydantic request/response +models are relocated out of 32 router modules into the centralized `src/backend/models.py`. +No business logic, route handler, auth dependency, or persistence code is added. +A new AST static guard (`tests/unit/test_models_centralized.py`) forbids `class X(BaseModel)` +under `routers/` going forward, with a single documented allowlist entry +(`canary.py::RunCycleRequest`, justified by a class-definition-time dependency inversion). + +## Verification performed + +1. **Secrets** — no hardcoded credentials, tokens, or API keys introduced + (pattern scan on added lines clean). No `.env`/manifest files in scope. +2. **Dependencies** — no `requirements*.txt` / `package.json` / `pyproject` changes; + no new packages, no version bumps → no new CVE surface. +3. **Auth boundaries** — **zero** `Depends(...)`, `require_admin`, `require_role`, + `AuthorizedAgent`, `OwnedAgentByName`, or `get_current_user` lines added or removed + across all 32 router diffs. Route handler signatures (and their guards) are untouched. +4. **Injection / boundary validation** — every moved `@field_validator` was confirmed + **byte-identical** in `models.py` (fan-out task-id regex `^[a-zA-Z0-9_-]{1,64}$`, + task/concurrency/timeout bounds, `policy` whitelist, loop `stop_signal` normalization). + The constants backing those validators moved with **identical values** + (`TASK_ID_RE`, `MAX_TASKS=50`, `MAX_CONCURRENCY=10`, `MAX_RUNS_LIMIT=100`, + `MAX_MESSAGE_LEN=100_000`, `MAX_STOP_SIGNAL_LEN=200`, `MAX_DELAY_SECONDS=3600`, + `MAX_TIMEOUT_PER_RUN=7200`, `CONTEXT_MAX_CHARS=4000`) and are no longer + duplicated in the routers (no divergent definition can drift). +5. **Security-relevant defaults** — permission/access fields (`require_email`, + `open_access`, `read_only`, `group_auth_mode="none"`, `validation_enabled=False`, + `welcome_enabled=False`, `allow_proactive`) appear identically on both `+`/`-` sides + of the diff — moves, not flips. No default weakened. +6. **Import integrity** — each router now imports its models from the centralized + `models` module; the prompt-injection-surface cap `CONTEXT_MAX_CHARS` is correctly + imported by `webhooks.py` rather than dropped. + +The PR additionally carries a byte-identical OpenAPI proof (covers field constraints, +required-ness, and defaults); this audit independently verified the custom-validator +logic that OpenAPI does not fully capture. + +## Findings + +#### CRITICAL +None + +#### HIGH +None + +#### MEDIUM / LOW +None + +## Recommendation + +**CLEAR** — no security impact. The change is a mechanical, behavior-preserving +relocation of model definitions; input validation and authorization surfaces are +provably unchanged, and a new static guard prevents regression of the invariant. From fbcd49468597534b265846a31796ebdc68529dde Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Tue, 23 Jun 2026 22:55:18 +0100 Subject: [PATCH 3/4] fix(tests): add pydantic[email] extra to test requirements (#654) INV-14 centralized EmailStr-bearing models (SendMessageRequest, etc.) into models.py, which the db layer imports non-tolerantly (db/schedules.py -> `from models import TaskExecutionStatus`). Building those schemas at import time now requires email-validator, so the unit-CI job failed at conftest collection: "ImportError: email-validator is not installed". The backend Docker image already installs pydantic[email]>=2.10.4; tests/requirements-test.txt had bare pydantic. Align the test env with production. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt index cff32ad2a..ecacbeb5a 100644 --- a/tests/requirements-test.txt +++ b/tests/requirements-test.txt @@ -9,7 +9,7 @@ websockets>=13.0 python-dotenv>=1.0.1 docker>=7.1.0 apscheduler>=3.11.0 -pydantic>=2.10.0 +pydantic[email]>=2.10.0 fastapi>=0.115.0 pyyaml>=6.0.0 python-jose[cryptography]>=3.3.0 From fa7b617cb216800a57b66371dcf8b6984de470c0 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Fri, 26 Jun 2026 00:40:53 +0100 Subject: [PATCH 4/4] docs(feature-flows): repoint stale model-home citations to models.py (INV-14, #654) The INV-14 centralization moved router-defined models into models.py, which left three flow docs citing a routers/X.py home this PR emptied: - scheduling.md: ScheduleUpdateRequest routers/schedules.py:76-86 -> models.py:1574 - audit-trail.md: fix wrong invariant number (#15 -> #14) and the now-inverted claim "response models in routers/audit_log.py" -> models.py:947-982 - business-validation.md: Schedule/execution response models routers/schedules.py -> models.py Citation-only; no behavioral or model-shape change. Co-Authored-By: Claude Opus 4.8 --- docs/memory/feature-flows/audit-trail.md | 2 +- docs/memory/feature-flows/business-validation.md | 2 +- docs/memory/feature-flows/scheduling.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/memory/feature-flows/audit-trail.md b/docs/memory/feature-flows/audit-trail.md index 72cd03457..8f1ffec51 100644 --- a/docs/memory/feature-flows/audit-trail.md +++ b/docs/memory/feature-flows/audit-trail.md @@ -542,4 +542,4 @@ Run e2e: `cd src/frontend && npm run test:e2e -- audit-dashboard.spec` - **Invariant #3** (Schema in schema.py, migrations in migrations.py): both updated together - **Invariant #4** (Router registration order): `/stats` before `/{event_id}` in router declaration - **Invariant #8** (Auth pattern): every endpoint uses `Depends(require_admin)` -- **Invariant #15** (Pydantic models centralized): all response models in `routers/audit_log.py` (consistent with other routers that own their response shapes) +- **Invariant #14** (Pydantic models centralized): all audit response models live in `src/backend/models.py` (`AuditLogEntry`/`AuditLogListResponse`/`AuditLogStatsResponse` at `models.py:947-982`), centralized out of `routers/audit_log.py` per #654 diff --git a/docs/memory/feature-flows/business-validation.md b/docs/memory/feature-flows/business-validation.md index cac7e6236..4f18678e6 100644 --- a/docs/memory/feature-flows/business-validation.md +++ b/docs/memory/feature-flows/business-validation.md @@ -76,7 +76,7 @@ class BusinessStatus(str, Enum): |-------|------|---------| | Backend | `src/backend/services/validation_service.py` | Core validation logic | | Backend | `src/backend/routers/internal.py` | `/validate-execution` endpoint | -| Backend | `src/backend/routers/schedules.py` | Schedule/execution response models | +| Backend | `src/backend/models.py` | Schedule/execution response models (centralized per Invariant #14, #654) | | Backend | `src/backend/db/schedules.py` | DB operations for validation | | Backend | `src/backend/models.py` | BusinessStatus enum | | Scheduler | `src/scheduler/service.py` | Triggers validation after execution | diff --git a/docs/memory/feature-flows/scheduling.md b/docs/memory/feature-flows/scheduling.md index aa458c30a..9a8184973 100644 --- a/docs/memory/feature-flows/scheduling.md +++ b/docs/memory/feature-flows/scheduling.md @@ -1364,7 +1364,7 @@ class Schedule(BaseModel): model: Optional[str] = None # Model override (MODEL-001). None = agent default ``` -**ScheduleUpdateRequest** (`src/backend/routers/schedules.py:76-86`): +**ScheduleUpdateRequest** (`src/backend/models.py:1574` — centralized per Invariant #14, #654): ```python class ScheduleUpdateRequest(BaseModel): # ... existing fields ...