mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(gateway): gate review diffs against the task's real parent branch (#444) The in-path PR-review gate's evidence diff (claim_gate_review) and the pr_pass conventions guard derived their diff base via parent_branch_for string surgery, which reuses the child branch's own team segment — wrong for every cross-team hop (a frontend child of a main_pm root derives a ref that never existed) and silently falls back to the repo default branch, so the reviewer judged the entire inherited base-branch content as the task's own work and failed acceptance criteria the task never touched. Bounced a live goals-tab fix three times, unfixable by branch surgery. The gate now resolves the base via resolve_parent_branch (the parent task's recorded branch_name, cross-team correct) and threads it as a new preferred_parent override through git.diff / list_changed_files / conventions_check_for_task — consulted only when no explicit base is given, so the pinned literal-base contract (base="HEAD~1") and every other diff caller (QA, doc, content) are byte-identical. Parent lookup fails open (derived-base fallback) like the other resolve_parent_branch call sites, and is skipped entirely while the conventions flag is off. Also excludes .uv-cache/ and .claude/ (agent worktrees, private uv cache) from the markdown prose scanner — both are repo-local tool dirs whose vendored/generated files tripped make reflow-check. Co-authored-by: Renn F <rennf93@users.noreply.github.com> * feat(vault): Obsidian vault V1 — projection core + input loop The vault is a rebuildable projection of the DB (never a source of truth), default-off behind ROBOCO_OBSIDIAN_VAULT_ENABLED + ROBOCO_VAULT_PATH. Projection core: VaultWriter materializes tasks/journals/A2A/agents as wikilinked markdown (id-suffixed stable filenames, alias-based links so renames never break, is_private journals excluded like the RAG corpus); event seams materialize on journal write and A2A send and touch task-note frontmatter at the status-transition chokepoint — all best-effort, a vault failure never blocks a verb. Shipped .obsidian config (Dataview, Kanban, team/status graph groups) + _meta dashboards; python -m roboco.vault rebuild/relocate (rebuild preserves the narrative section). Auditor narrative duty: curate_vault content verb (auditor-only, playbook-curation pattern) spawned by a dedicated root-completion hook with its own cooldown — fully separate from _dispatch_audit_work, whose scheduled-sweep/alert-producer revival belongs to the queued fleet task. Input loop: VaultIntakeEngine (ROBOCO_VAULT_INTAKE_*) watches the intake folder for #roboco-tagged notes and materializes each as ONE held draft (confirmed_by_human=False, Secretary-owned, source=vault_note, excluded by the dispatchers via _is_held_ceo_source) with local-model extraction and a deterministic fallback; vault_seen_notes ledger (migration 070) keyed on path+content-hash (the CEO-feedback callout is stripped before hashing so the engine's own append never self-triggers); per-cycle and open-draft caps. Nothing auto-starts. * fix(vault): integrate with master — re-chain migration 070 onto 069, mypy-clean tests The vault branch was cut from slave before the sequence-gate promotion, so migration 070 chained from 068 while master already carried 069 — two heads on merge. Master is now merged in and 070 revises 069. Vault test files also get mypy-clean mock idioms (monkeypatch.setattr over method assignment; await_args narrowed before access). * fix(scripts): dedupe SKIP_DIRS again after the master merge Master still carries the twin-merge duplicate (its dedupe hotfix is an unmerged PR); the merge re-imported it here. * fix(vault): reflow hard-wrapped prose in the vault asset templates * chore(config): exclude .uv-cache and .claude from deptry's scan scope Same repo-local tool dirs the prose scanner skips; the standalone deptry target walks the repo root and drowned in the cache's unpacked wheels. * fix(vault): board-review activation path for vault drafts + relocate graft The input loop's held-artifact posture was a dead end: vault_note drafts were unconditionally held by _is_held_ceo_source, owned by the verbless Secretary, hidden from the panel's approval surfaces by team, and the open-drafts cap counted them forever — the engine self-bricked after ten notes. Vault drafts now ride the intake board-review path instead: a tagged note becomes a PENDING Product-Owner-assigned Board draft (the exact confirm_live_draft board shape), the board reviews it, and only the CEO's approve_and_start makes it deliverable — never-auto-starts now rests on the board gate, proven by tests against the real dispatchers. The cap counts only drafts still awaiting the CEO (team==BOARD, non-terminal), so approval and cancellation both free it. relocate into an existing personal vault now grafts old_root/RoboCo as a direct child (refusing loudly if RoboCo/ already exists there) and adds only absent .obsidian/_meta files — a personal vault's config is never clobbered. An absent destination keeps the whole-tree move. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2398 lines
91 KiB
Python
2398 lines
91 KiB
Python
"""
|
|
SQLAlchemy Table Definitions
|
|
|
|
ORM mappings for all RoboCo data models.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
from uuid import UUID as PyUUID
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
BigInteger,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Enum,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
text,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from roboco.db.base import Base
|
|
from roboco.models.a2a import A2AConversationStatus, A2AMessageKind
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
AssignmentScope,
|
|
BlockerResolverType,
|
|
Complexity,
|
|
HandoffStatus,
|
|
JournalEntryType,
|
|
ModelProvider,
|
|
NotificationPriority,
|
|
NotificationType,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
Team,
|
|
)
|
|
from roboco.models.work_session import WorkSessionStatus
|
|
|
|
# Python class name → canonical postgres enum name (only the cases where
|
|
# the lowercased class name does NOT match the migration's `name=...`).
|
|
# Audited from alembic/versions/*.py — every other StrEnum uses
|
|
# lower(class_name), so we default to that.
|
|
_PG_ENUM_NAME_OVERRIDES: dict[str, str] = {
|
|
# The foundation's `Role` class binds to the postgres `agentrole` enum
|
|
# (see alembic 001 + 012). Without this override, SQLAlchemy infers
|
|
# `role` from the class name, producing the `operator does not exist:
|
|
# agentrole = role` regression that smoke run 2 hit.
|
|
"Role": "agentrole",
|
|
}
|
|
|
|
|
|
def _str_enum(enum_cls: type) -> Enum:
|
|
"""SQLAlchemy Enum that serializes by `.value` (lowercase) for StrEnum types.
|
|
|
|
Matches the lowercase values declared in alembic/versions/001_initial_schema.py.
|
|
Without values_callable, SQLAlchemy uses `.name` (uppercase) which does not
|
|
match the alembic-declared enum members.
|
|
|
|
The `name=` is pinned to the canonical postgres enum name so that
|
|
``Base.metadata.create_all`` (test setup) produces the same enum
|
|
types the migrations create. Default is ``lower(class_name)`` —
|
|
matches every alembic migration's name=...; the only override is
|
|
``Role`` → ``agentrole`` (E2 fix).
|
|
"""
|
|
name = _PG_ENUM_NAME_OVERRIDES.get(enum_cls.__name__, enum_cls.__name__.lower())
|
|
return Enum(
|
|
enum_cls,
|
|
name=name,
|
|
values_callable=lambda obj: [m.value for m in obj],
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# AGENT TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class AgentTable(Base):
|
|
"""SQLAlchemy table for agents."""
|
|
|
|
__tablename__ = "agents"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
slug: Mapped[str] = mapped_column(
|
|
String(50), unique=True, nullable=False, index=True
|
|
)
|
|
|
|
# Role & Team
|
|
role: Mapped[AgentRole] = mapped_column(_str_enum(AgentRole), nullable=False)
|
|
team: Mapped[Team | None] = mapped_column(_str_enum(Team), nullable=True)
|
|
|
|
# Status
|
|
status: Mapped[AgentStatus] = mapped_column(
|
|
_str_enum(AgentStatus), nullable=False, default=AgentStatus.OFFLINE
|
|
)
|
|
current_task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
|
|
# Configuration (stored as JSON)
|
|
model_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
|
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
|
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# Permissions (stored as JSON)
|
|
permissions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
|
|
# Metrics (stored as JSON)
|
|
metrics: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
|
|
# Journal
|
|
journal_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
|
|
# Description
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships - use lazy="joined" for single optional relationship
|
|
current_task: Mapped["TaskTable | None"] = relationship(
|
|
"TaskTable", foreign_keys=[current_task_id], lazy="joined"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# TASK TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class TaskTable(Base):
|
|
"""SQLAlchemy table for tasks."""
|
|
|
|
__tablename__ = "tasks"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# Server-derived architectural constraints (project baseline conventions
|
|
# block), moved out of `description` (migration 068). Nullable: flag-off /
|
|
# no-conventions / pre-migration rows have none. The conventions also
|
|
# reach the agent at spawn via the ambient block — this column is for
|
|
# panel visibility, not agent correctness.
|
|
constraints: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
acceptance_criteria: Mapped[list[str]] = mapped_column(
|
|
ARRAY(String), nullable=False
|
|
)
|
|
# Stable id per acceptance_criteria element (1:1, same order). Lets a child
|
|
# task's parent_ac_refs point at specific parent criteria (migration 036).
|
|
acceptance_criteria_ids: Mapped[list[str]] = mapped_column(
|
|
ARRAY(String), nullable=False, default=list
|
|
)
|
|
# On a decomposition child: the parent AC ids this child is responsible for
|
|
# (empty on non-children). Powers the coverage + roll-up AC gates.
|
|
parent_ac_refs: Mapped[list[str]] = mapped_column(
|
|
ARRAY(String), nullable=False, default=list
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
# ponytail: transient, non-persisted — set only by TaskService.cancel()
|
|
# to surface orphaned parent-AC coverage without a response-schema
|
|
# change. Declared here only so mypy accepts the attribute; SQLAlchemy
|
|
# never sees it (TYPE_CHECKING is False at runtime).
|
|
orphaned_parent_acs: list[str] | None
|
|
|
|
# Status
|
|
status: Mapped[TaskStatus] = mapped_column(
|
|
_str_enum(TaskStatus), nullable=False, default=TaskStatus.PENDING, index=True
|
|
)
|
|
# Only meaningful when status == BLOCKED. Tells the dispatcher whether
|
|
# another agent can be respawned to resolve the block (`agent`) or
|
|
# whether the block is waiting on a human and spawning is wasted work
|
|
# (`human`). NULL for never-blocked tasks and for pre-existing rows.
|
|
blocker_resolver_type: Mapped[BlockerResolverType | None] = mapped_column(
|
|
_str_enum(BlockerResolverType), nullable=True
|
|
)
|
|
# Agent who raised the current block/escalation. Escalate reassigns the
|
|
# task to the resolver (PM) so they can work the fix, which loses the
|
|
# original dev's identity. We stash it here so `unblock` can flip
|
|
# assignment back and the orchestrator respawns the right agent. NULL
|
|
# = never blocked or pre-migration row.
|
|
blocker_raised_by: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
|
|
|
|
# Task Type & Git Configuration (all tasks follow git workflow)
|
|
task_type: Mapped[TaskType] = mapped_column(
|
|
_str_enum(TaskType), nullable=False, default=TaskType.CODE
|
|
)
|
|
nature: Mapped[TaskNature] = mapped_column(
|
|
_str_enum(TaskNature), nullable=False, default=TaskNature.TECHNICAL
|
|
)
|
|
|
|
# Project & Branch (branch auto-created on claim).
|
|
# Nullable: a board/fan-out task carries `product_id` (a cell->project map)
|
|
# instead of a single project — it does no git itself; its cell subtasks
|
|
# each resolve a real project from the product. A task must have one or the
|
|
# other (enforced in TaskCreate).
|
|
project_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
product_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
branch_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
work_session_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("work_sessions.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
|
|
# PR Tracking
|
|
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
|
|
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
|
|
docs_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
pr_created: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
|
|
# Board review handoff: a board/coordination task stays pending while the
|
|
# Product Owner + Head of Marketing review it. Set True once both reviewers
|
|
# finish, so the CEO's Approve & Start button appears only after the board
|
|
# is actually done — never on a freshly created pending board task.
|
|
board_review_complete: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False
|
|
)
|
|
|
|
# Ownership
|
|
created_by: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
|
|
)
|
|
assigned_to: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
team: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False, index=True)
|
|
|
|
# Relationships
|
|
parent_task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
dependency_ids: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list
|
|
)
|
|
blocker_ids: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list
|
|
)
|
|
# Dependencies that have since completed and been cleared from
|
|
# ``dependency_ids`` — kept so the unblock briefing can tell the revived
|
|
# dependent which upstream task just landed.
|
|
completed_dependency_ids: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list, server_default="{}"
|
|
)
|
|
|
|
# Ordering (for sibling tasks under the same parent)
|
|
sequence: Mapped[int] = mapped_column(
|
|
Integer, default=0, nullable=False, index=True
|
|
)
|
|
|
|
# Sequenced batch intake ("Mega task"): a batch of top-level tasks created
|
|
# together. ``batch_id`` groups them; the three descriptors are the per-task
|
|
# collision surface the SequencingService reads to compute dependency waves.
|
|
batch_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True, index=True
|
|
)
|
|
intends_to_touch: Mapped[list[str] | None] = mapped_column(
|
|
ARRAY(String), nullable=True
|
|
)
|
|
adds_migration: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False, server_default="false"
|
|
)
|
|
touches_shared: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False, server_default="false"
|
|
)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
claimed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
claimed_by: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
started_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
completed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
target_date: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Planning (stored as JSON)
|
|
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
estimated_complexity: Mapped[Complexity] = mapped_column(
|
|
_str_enum(Complexity), nullable=False, default=Complexity.MEDIUM
|
|
)
|
|
|
|
# Execution (stored as JSON)
|
|
checkpoints: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
progress_updates: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Artifacts (stored as JSON)
|
|
commits: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
documents: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Documentation
|
|
dev_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
qa_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
auditor_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Review Status
|
|
self_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
qa_verified: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
# Rework: incremented on every transition into needs_revision so the
|
|
# rework rate is an O(1) column read instead of an audit_log scan.
|
|
revision_count: Mapped[int] = mapped_column(
|
|
Integer, default=0, server_default="0", nullable=False
|
|
)
|
|
|
|
# Quick Context
|
|
quick_context: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Proactive Knowledge Context (injected when task is claimed)
|
|
proactive_context: Mapped[dict[str, Any] | None] = mapped_column(
|
|
JSON, nullable=True
|
|
)
|
|
|
|
# Structured content (migration 041). notes_structured is the typed source
|
|
# of truth for every role's note; the TEXT note columns above are a derived
|
|
# mirror. orchestration_markers holds the machine markers split out of
|
|
# quick_context (never human-facing). pr_reviewer_notes is the reviewer's
|
|
# own slot so a review no longer overwrites qa_notes / dev_notes.
|
|
pr_reviewer_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
doc_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
notes_structured: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
orchestration_markers: Mapped[dict[str, Any] | None] = mapped_column(
|
|
JSON, nullable=True
|
|
)
|
|
|
|
# Gateway coordination (added in migration 006_gateway_columns).
|
|
# active_claimant_id + last_heartbeat_at implement the single-claimant
|
|
# invariant: only one agent holds a task at a time and they prove
|
|
# liveness via periodic heartbeats. Stale claims are cleaned up by the
|
|
# trigger filter.
|
|
active_claimant_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# pre_block_* snapshot the task's state at the moment it was blocked so
|
|
# `unblock(restore=True)` can return it to its prior status/assignee/
|
|
# metadata instead of dumping the agent into pending.
|
|
pre_block_state: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
pre_block_assignee: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
pre_block_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
|
JSON, nullable=True
|
|
)
|
|
# acceptance_criteria_status: per-criterion records of the form
|
|
# {"criterion": "<text>", "referencing_artifact_id": "<commit-sha|note-id>"}.
|
|
# The tracing gate refuses transitions until every criterion has one.
|
|
acceptance_criteria_status: Mapped[list[dict[str, Any]]] = mapped_column(
|
|
JSON, nullable=False, default=list
|
|
)
|
|
# qa_evidence_inspected: set true by claim_review when the QA agent
|
|
# inspects the inline diff/commits. The pass-review gate refuses the
|
|
# pass transition unless this is true.
|
|
qa_evidence_inspected: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False
|
|
)
|
|
|
|
# Prompter origin tracking: tasks drafted by the Prompter LLM assistant
|
|
# require human confirmation before entering the workflow. The task creation
|
|
# route enforces that prompter-originated tasks cannot bypass human review.
|
|
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual")
|
|
confirmed_by_human: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False
|
|
)
|
|
|
|
# Relationships
|
|
creator: Mapped["AgentTable"] = relationship(
|
|
"AgentTable", foreign_keys=[created_by], lazy="joined"
|
|
)
|
|
assignee: Mapped["AgentTable | None"] = relationship(
|
|
"AgentTable", foreign_keys=[assigned_to], lazy="joined"
|
|
)
|
|
parent_task: Mapped["TaskTable | None"] = relationship(
|
|
"TaskTable", remote_side=[id], lazy="select"
|
|
)
|
|
project: Mapped["ProjectTable | None"] = relationship(
|
|
"ProjectTable", foreign_keys=[project_id], lazy="joined"
|
|
)
|
|
# Per-cell project map for an ad-hoc (non-Product) coordination root —
|
|
# mirrors product_projects but is owned by the task itself, so a MegaTask
|
|
# root-subtask can target a per-cell map mixing projects from different
|
|
# products / OSS libs. selectin so the fan-out resolvers see the map on
|
|
# any task fetch; passive_deletes trusts the ON DELETE CASCADE FK.
|
|
cell_projects: Mapped[list["TaskCellProjectTable"]] = relationship(
|
|
"TaskCellProjectTable",
|
|
back_populates="task",
|
|
cascade="all, delete-orphan",
|
|
passive_deletes=True,
|
|
lazy="selectin",
|
|
)
|
|
|
|
__table_args__ = (
|
|
# Composite indexes for common queries
|
|
Index("ix_tasks_team_status", "team", "status"),
|
|
Index("ix_tasks_assigned_status", "assigned_to", "status"),
|
|
Index("ix_tasks_created_by_status", "created_by", "status"),
|
|
Index("ix_tasks_project_status", "project_id", "status"),
|
|
Index("ix_tasks_product_status", "product_id", "status"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PROJECT TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class ProjectTable(Base):
|
|
"""SQLAlchemy table for projects (git repositories)."""
|
|
|
|
__tablename__ = "projects"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
slug: Mapped[str] = mapped_column(
|
|
String(50), unique=True, nullable=False, index=True
|
|
)
|
|
|
|
# Git Configuration
|
|
git_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
default_branch: Mapped[str] = mapped_column(
|
|
String(100), nullable=False, default="master"
|
|
)
|
|
protected_branches: Mapped[list[str]] = mapped_column(
|
|
ARRAY(String), default=lambda: ["main", "master"]
|
|
)
|
|
git_token_encrypted: Mapped[str | None] = mapped_column(
|
|
Text, nullable=True
|
|
) # Fernet-encrypted GitHub PAT
|
|
|
|
# CI/CD Commands (optional)
|
|
test_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
lint_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
format_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
typecheck_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
build_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
# Fast pre-submit gate command (lint+types+complexity, no tests). When set,
|
|
# the agent i_am_done gate runs this in the dev's workspace instead of the
|
|
# lint/typecheck pair — e.g. "make gate".
|
|
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
|
|
# Autonomous maintenance opt-in (multi-repo CI-watch). Default-off: a
|
|
# project is watched only when ci_watch_enabled is set; ci_watch_workflow
|
|
# scopes the CI signal to one workflow file (null → the engine default).
|
|
ci_watch_enabled: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, server_default="false", default=False
|
|
)
|
|
ci_watch_workflow: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
# Video-engine opt-in. The global ROBOCO_VIDEO_ENGINE_ENABLED flag arms the
|
|
# subsystem; a project opts in via video_engine_enabled before any
|
|
# authoring task opens against its motion/ dir. Default-off, mirroring
|
|
# ci_watch_enabled (migration 048).
|
|
video_engine_enabled: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, server_default="false", default=False
|
|
)
|
|
|
|
# Dependency-update bot opt-in. A project participates only when
|
|
# dep_update_command is set (e.g. "uv lock --upgrade"); dep_update_paths are
|
|
# the lockfile globs the probe inspects (null → infer uv.lock/pnpm-lock.yaml).
|
|
dep_update_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
dep_update_paths: Mapped[list[str] | None] = mapped_column(
|
|
ARRAY(String), nullable=True
|
|
)
|
|
|
|
# Sandboxed per-agent-spawn DB/Redis opt-in. A project participates only
|
|
# when sandbox_services is set (e.g. ["postgres", "redis"]); values are
|
|
# validated by the Project pydantic model before reaching here.
|
|
sandbox_services: Mapped[list[str] | None] = mapped_column(
|
|
ARRAY(String), nullable=True
|
|
)
|
|
|
|
# Access Control
|
|
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
|
allowed_agents: Mapped[list[PyUUID] | None] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), nullable=True
|
|
)
|
|
|
|
# Runtime State
|
|
workspace_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
last_synced_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
head_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
|
|
# Metadata
|
|
created_by: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
|
|
)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
creator: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
|
|
|
|
__table_args__ = (
|
|
Index("ix_projects_cell", "assigned_cell"),
|
|
Index("ix_projects_active", "is_active"),
|
|
)
|
|
|
|
|
|
class ProductTable(Base):
|
|
"""A product groups a per-cell Project mapping (a repo topology)."""
|
|
|
|
__tablename__ = "products"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
slug: Mapped[str] = mapped_column(
|
|
String(50), unique=True, nullable=False, index=True
|
|
)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
created_by: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
creator: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
|
|
cells: Mapped[list["ProductProjectTable"]] = relationship(
|
|
"ProductProjectTable",
|
|
back_populates="product",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
|
|
|
|
class ProductProjectTable(Base):
|
|
"""One Project per cell per Product (the per-cell routing map)."""
|
|
|
|
__tablename__ = "product_projects"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
product_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("products.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
team: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
|
project_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="RESTRICT"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
product: Mapped["ProductTable"] = relationship(
|
|
"ProductTable", back_populates="cells"
|
|
)
|
|
project: Mapped["ProjectTable"] = relationship(
|
|
"ProjectTable", foreign_keys=[project_id], lazy="joined"
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("product_id", "team", name="uq_product_projects_product_team"),
|
|
)
|
|
|
|
|
|
class TaskCellProjectTable(Base):
|
|
"""One Project per cell for an ad-hoc (non-Product) coordination root.
|
|
|
|
Mirrors ``product_projects`` but the map is owned by the task, not a Product:
|
|
a MegaTask root-subtask that spans multiple cells (and may mix per-cell
|
|
projects from different products / OSS libs) carries its per-cell routing
|
|
here instead of a ``product_id``. The root itself does git per repo (it cuts
|
|
``feature/main_pm/{root}`` and opens a root->master PR per repo exactly like a
|
|
Product fan-out root); the cell children each resolve their project from this
|
|
map via ``_resolve_subtask_project``. ``UNIQUE (task_id, team)`` enforces one
|
|
project per cell per task.
|
|
"""
|
|
|
|
__tablename__ = "task_cell_projects"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
task_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
team: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
|
project_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="RESTRICT"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
task: Mapped["TaskTable"] = relationship(
|
|
"TaskTable", back_populates="cell_projects"
|
|
)
|
|
project: Mapped["ProjectTable"] = relationship(
|
|
"ProjectTable", foreign_keys=[project_id], lazy="joined"
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("task_id", "team", name="uq_task_cell_projects_task_team"),
|
|
)
|
|
|
|
|
|
class PitchTable(Base):
|
|
"""A Board proposal the CEO approves to auto-provision a product.
|
|
|
|
Independent of the task lifecycle: a pitch carries its own status
|
|
(proposed -> provisioned/rejected/failed). On approval the provisioning
|
|
flow creates repos, registers Projects (+ a Product when multi-cell), and
|
|
seeds a Main-PM delivery task, recording the produced ids here.
|
|
"""
|
|
|
|
__tablename__ = "pitches"
|
|
|
|
id: Mapped[PyUUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
slug: Mapped[str] = mapped_column(
|
|
String(50), unique=True, nullable=False, index=True
|
|
)
|
|
problem: Mapped[str] = mapped_column(Text, nullable=False)
|
|
proposed_solution: Mapped[str] = mapped_column(Text, nullable=False)
|
|
target_cells: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
status: Mapped[str] = mapped_column(
|
|
String(20), nullable=False, default="proposed", index=True
|
|
)
|
|
created_by: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
|
decided_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
decision_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
provisioned_product_id: Mapped[PyUUID | None] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True
|
|
)
|
|
provisioned_project_ids: Mapped[list[str] | None] = mapped_column(
|
|
JSON, nullable=True
|
|
)
|
|
seed_task_id: Mapped[PyUUID | None] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
|
|
class PlaybookTable(Base):
|
|
"""A curated, reusable procedure: when to use it + the steps.
|
|
|
|
A *learning* records "this happened"; a *playbook* records "here is how to do
|
|
X". An agent drafts one (status=draft); the Auditor approves it
|
|
(status=approved) and only then is it embedded into the PLAYBOOKS RAG index
|
|
and auto-suggested. Orthogonal to the task lifecycle — its own entity, no
|
|
task status. Status is a plain string (the PlaybookStatus StrEnum carries the
|
|
valid values at the service layer), matching the pitches convention.
|
|
"""
|
|
|
|
__tablename__ = "playbooks"
|
|
|
|
id: Mapped[PyUUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
slug: Mapped[str] = mapped_column(
|
|
String(80), unique=True, nullable=False, index=True
|
|
)
|
|
problem: Mapped[str] = mapped_column(Text, nullable=False)
|
|
procedure: Mapped[str] = mapped_column(Text, nullable=False)
|
|
tags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
team: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="org")
|
|
source_task_ids: Mapped[list[str]] = mapped_column(
|
|
JSON, nullable=False, default=list
|
|
)
|
|
status: Mapped[str] = mapped_column(
|
|
String(20), nullable=False, default="draft", index=True
|
|
)
|
|
created_by: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
|
approved_by: Mapped[PyUUID | None] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
approved_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# Distinct from approval: who retired it (archive/reject) and when. Stamping
|
|
# the archiver into approved_by overwrote the approval provenance.
|
|
archived_by: Mapped[PyUUID | None] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True
|
|
)
|
|
archived_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# Durable index-state: False on the approve status flip, set True only
|
|
# after a successful index_playbook. A startup reconcile re-indexes
|
|
# APPROVED rows left False by a mid-approval embedder outage.
|
|
indexed_ok: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
)
|
|
indexed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
|
|
class SecretaryDirectiveTable(Base):
|
|
"""One action the Secretary took (or queued) on the CEO's behalf.
|
|
|
|
Direct (low-risk) directives are written already ``executed``; gated
|
|
(high-impact) ones are ``pending`` until the CEO confirms, then run. The
|
|
full row is the command audit trail.
|
|
"""
|
|
|
|
__tablename__ = "secretary_directives"
|
|
|
|
id: Mapped[PyUUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="pending", index=True
|
|
)
|
|
requested_by: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
|
requested_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
decided_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
decided_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
# =============================================================================
|
|
# WORK SESSION TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class WorkSessionTable(Base):
|
|
"""SQLAlchemy table for work sessions (git working sessions)."""
|
|
|
|
__tablename__ = "work_sessions"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
project_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
task_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
agent_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
|
|
# Branch Management
|
|
branch_name: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
base_branch: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
target_branch: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
|
|
# Lifecycle
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
ended_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
status: Mapped[WorkSessionStatus] = mapped_column(
|
|
_str_enum(WorkSessionStatus),
|
|
nullable=False,
|
|
default=WorkSessionStatus.ACTIVE,
|
|
index=True,
|
|
)
|
|
|
|
# Audit Trail
|
|
commits: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
files_modified: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# PR Tracking
|
|
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
pr_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
pr_created_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
pr_merged_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Merge tracking
|
|
merged_by: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
|
|
# Toolchain matching — the Python the workspace was provisioned with, and
|
|
# whether the project's test suite can actually be executed in it.
|
|
toolchain_python: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
toolchain_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
project: Mapped["ProjectTable"] = relationship("ProjectTable", lazy="joined")
|
|
agent: Mapped["AgentTable | None"] = relationship(
|
|
"AgentTable", foreign_keys=[agent_id], lazy="joined"
|
|
)
|
|
merger: Mapped["AgentTable | None"] = relationship(
|
|
"AgentTable", foreign_keys=[merged_by], lazy="select"
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_work_sessions_project_status", "project_id", "status"),
|
|
Index("ix_work_sessions_task", "task_id"),
|
|
Index("ix_work_sessions_agent_status", "agent_id", "status"),
|
|
# A task is owned by one agent at a time → at most one ACTIVE session.
|
|
# Without this, a re-claim by a different agent left duplicate ACTIVE
|
|
# rows and get_active_for_task crashed with MultipleResultsFound,
|
|
# wedging i_will_plan into a respawn loop. Mirrored by migration 047.
|
|
Index(
|
|
"uq_work_sessions_one_active_per_task",
|
|
"task_id",
|
|
unique=True,
|
|
postgresql_where=text("status = 'active'"),
|
|
),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# NOTIFICATION TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class NotificationTable(Base):
|
|
"""SQLAlchemy table for notifications."""
|
|
|
|
__tablename__ = "notifications"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
type: Mapped[NotificationType] = mapped_column(
|
|
_str_enum(NotificationType), nullable=False, index=True
|
|
)
|
|
priority: Mapped[NotificationPriority] = mapped_column(
|
|
_str_enum(NotificationPriority),
|
|
nullable=False,
|
|
default=NotificationPriority.NORMAL,
|
|
)
|
|
|
|
# Routing
|
|
from_agent: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
|
|
)
|
|
to_agents: Mapped[list[UUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), nullable=False
|
|
)
|
|
|
|
# Content
|
|
subject: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
# Acknowledgment
|
|
requires_ack: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
acked_by: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list
|
|
)
|
|
acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
|
|
# Context
|
|
related_task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
related_message_ids: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list
|
|
)
|
|
|
|
# Timing
|
|
timestamp: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
expires_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Read tracking
|
|
read_by: Mapped[list[PyUUID]] = mapped_column(
|
|
ARRAY(UUID(as_uuid=True)), default=list
|
|
)
|
|
|
|
# Delivery tracking
|
|
delivered_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
# Relationships
|
|
sender: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
|
|
related_task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
|
|
|
|
__table_args__ = (
|
|
# Composite indexes for notification queries
|
|
Index("ix_notifications_from_agent_timestamp", "from_agent", "timestamp"),
|
|
Index("ix_notifications_type_priority", "type", "priority"),
|
|
Index("ix_notifications_timestamp_priority", "timestamp", "priority"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# JOURNAL TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class JournalTable(Base):
|
|
"""SQLAlchemy table for journals."""
|
|
|
|
__tablename__ = "journals"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
agent_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
unique=True,
|
|
)
|
|
|
|
# Metadata
|
|
total_entries: Mapped[int] = mapped_column(Integer, default=0)
|
|
last_entry_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Summary
|
|
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
summary_updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Growth metrics (stored as JSON)
|
|
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships - use lazy="select" for entries collection to avoid N+1
|
|
agent: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
|
|
entries: Mapped[list["JournalEntryTable"]] = relationship(
|
|
"JournalEntryTable", back_populates="journal", lazy="select"
|
|
)
|
|
|
|
|
|
class JournalEntryTable(Base):
|
|
"""SQLAlchemy table for journal entries."""
|
|
|
|
__tablename__ = "journal_entries"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
journal_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("journals.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Content
|
|
type: Mapped[JournalEntryType] = mapped_column(
|
|
_str_enum(JournalEntryType), nullable=False, index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
# Context
|
|
task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
|
|
# Metadata
|
|
timestamp: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# Sentiment
|
|
sentiment: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
|
|
# Visibility
|
|
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
journal: Mapped["JournalTable"] = relationship(
|
|
"JournalTable", back_populates="entries"
|
|
)
|
|
|
|
__table_args__ = (
|
|
# Composite indexes for journal entry queries
|
|
Index("ix_journal_entries_journal_timestamp", "journal_id", "timestamp"),
|
|
Index("ix_journal_entries_journal_type", "journal_id", "type"),
|
|
Index("ix_journal_entries_task_id", "task_id"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# HANDOFF TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class HandoffTable(Base):
|
|
"""
|
|
SQLAlchemy table for structured documentation handoffs.
|
|
|
|
STATUS: RESERVED FOR FUTURE USE
|
|
===============================
|
|
This table exists in the schema but has no service layer or API yet.
|
|
|
|
Current Implementation:
|
|
Handoffs use the simpler `dev_notes` + `handoff_summary` parameters
|
|
in the submit/open_pr flow, stored directly on the task.
|
|
|
|
Future Enhancement:
|
|
This table enables richer, structured handoff documents with:
|
|
- Categorized changes (new functionality, breaking changes)
|
|
- Required vs optional documentation items
|
|
- Code samples, gotchas, key learnings
|
|
- Linked commits and file locations
|
|
|
|
To implement: create HandoffService + API routes + MCP tools.
|
|
"""
|
|
|
|
__tablename__ = "handoffs"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
task_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Summary
|
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
# What Changed (stored as JSON arrays)
|
|
new_functionality: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
modified_behavior: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
breaking_changes: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# Documentation Needed (stored as JSON)
|
|
required_docs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
optional_docs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Key Commits (stored as JSON)
|
|
commits: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
|
|
|
|
# Code Locations (stored as JSON)
|
|
new_files: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
|
|
modified_files: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
|
|
|
|
# Conversations (stored as JSON)
|
|
key_conversations: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Code Samples (stored as JSON)
|
|
code_samples: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Gotchas (stored as JSON)
|
|
gotchas: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
|
|
|
|
# Related Documentation
|
|
related_docs: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# Changelog Entry
|
|
changelog_entry: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Key Learnings (stored as JSON)
|
|
key_learnings: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
key_decisions: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
|
|
|
|
# Questions
|
|
questions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
|
|
|
# Dev Notes Location
|
|
dev_notes_location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
|
|
# Status
|
|
status: Mapped[HandoffStatus] = mapped_column(
|
|
_str_enum(HandoffStatus),
|
|
nullable=False,
|
|
default=HandoffStatus.PENDING,
|
|
index=True,
|
|
)
|
|
assigned_to: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
claimed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
completed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Documenter feedback
|
|
documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Relationships
|
|
task: Mapped["TaskTable"] = relationship("TaskTable", lazy="joined")
|
|
assignee: Mapped["AgentTable | None"] = relationship("AgentTable", lazy="joined")
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("task_id", name="uq_handoffs_task_id"),
|
|
# Indexes for handoff queries
|
|
Index("ix_handoffs_assigned_status", "assigned_to", "status"),
|
|
Index("ix_handoffs_status_created", "status", "created_at"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# INDEXED DOCUMENT TABLE (Knowledge Base tracking)
|
|
# =============================================================================
|
|
|
|
|
|
class IndexedDocumentTable(Base):
|
|
"""
|
|
Track documents indexed into the knowledge base.
|
|
|
|
This provides:
|
|
- Actual document count (vs chunk count from vector DB)
|
|
- Browsing capability for the KB UI
|
|
- Source tracking for re-indexing
|
|
"""
|
|
|
|
__tablename__ = "indexed_documents"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
|
|
# Index type (code, docs, conversations, journals, errors, standards, etc.)
|
|
index_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
|
|
# Source information
|
|
source: Mapped[str] = mapped_column(String(1000), nullable=False)
|
|
source_hash: Mapped[str] = mapped_column(
|
|
String(64), nullable=False
|
|
) # SHA256 for dedup
|
|
|
|
# Document title (extracted or filename)
|
|
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
|
|
# Content preview (first 500 chars for UI)
|
|
preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Chunk count for this document
|
|
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
# Extra data (extracted during indexing)
|
|
extra_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
|
|
# Timestamps
|
|
indexed_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
__table_args__ = (
|
|
# Prevent duplicate indexing of same source
|
|
UniqueConstraint("index_type", "source_hash", name="uq_indexed_doc_source"),
|
|
Index("ix_indexed_docs_type_time", "index_type", "indexed_at"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# A2A CONVERSATION TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class A2AConversationTable(Base):
|
|
"""
|
|
Persistent A2A conversation between two agents.
|
|
|
|
Uses canonical ordering (agent_a < agent_b) for unique pair identification.
|
|
This enables persistent chat history across agent spawns.
|
|
"""
|
|
|
|
__tablename__ = "a2a_conversations"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
|
|
# The pair (canonical order: lexically smaller first)
|
|
agent_a: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
agent_b: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
|
|
# Context
|
|
topic: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
|
|
# Status
|
|
status: Mapped[A2AConversationStatus] = mapped_column(
|
|
_str_enum(A2AConversationStatus),
|
|
nullable=False,
|
|
default=A2AConversationStatus.ACTIVE,
|
|
index=True,
|
|
)
|
|
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Stats
|
|
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
unread_by_a: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
unread_by_b: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
)
|
|
last_message_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
|
|
messages: Mapped[list["A2AMessageTable"]] = relationship(
|
|
"A2AMessageTable", back_populates="conversation", lazy="select"
|
|
)
|
|
|
|
__table_args__ = (
|
|
# Unique pair + topic combination
|
|
UniqueConstraint("agent_a", "agent_b", "topic", name="uq_a2a_pair_topic"),
|
|
# Ensure canonical ordering
|
|
# CheckConstraint("agent_a < agent_b", name="ck_a2a_agent_order"),
|
|
# Composite indexes
|
|
Index("ix_a2a_conv_pair", "agent_a", "agent_b"),
|
|
Index("ix_a2a_conv_status_updated", "status", "updated_at"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# A2A MESSAGE TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class A2AMessageTable(Base):
|
|
"""
|
|
Individual message in persistent A2A conversation.
|
|
|
|
Supports threading via response_to_id and read tracking.
|
|
"""
|
|
|
|
__tablename__ = "a2a_messages"
|
|
|
|
# Identity
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
conversation_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("a2a_conversations.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Sender (must be agent_a or agent_b from conversation)
|
|
from_agent: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
|
|
# Content
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
message_kind: Mapped[A2AMessageKind] = mapped_column(
|
|
_str_enum(A2AMessageKind),
|
|
nullable=False,
|
|
default=A2AMessageKind.MESSAGE,
|
|
)
|
|
# Capability this A2A concerns (e.g. ``code_review``); nullable for legacy /
|
|
# unspecified messages. Migration 054.
|
|
skill: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
|
|
# Threading
|
|
response_to_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("a2a_messages.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
requires_response: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False
|
|
)
|
|
|
|
# Read tracking
|
|
read_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Edit support
|
|
edited_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
|
|
|
# Relationships
|
|
conversation: Mapped["A2AConversationTable"] = relationship(
|
|
"A2AConversationTable", back_populates="messages"
|
|
)
|
|
response_to: Mapped["A2AMessageTable | None"] = relationship(
|
|
"A2AMessageTable", remote_side=[id], lazy="select"
|
|
)
|
|
|
|
__table_args__ = (
|
|
# Composite indexes for message queries
|
|
Index("ix_a2a_msg_conv_created", "conversation_id", "created_at"),
|
|
Index("ix_a2a_msg_from_created", "from_agent", "created_at"),
|
|
# Partial index for pending responses
|
|
Index(
|
|
"ix_a2a_msg_pending",
|
|
"conversation_id",
|
|
postgresql_where=(requires_response.is_(True)),
|
|
),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# ORCHESTRATOR WAITING RECORDS
|
|
# =============================================================================
|
|
|
|
|
|
class WaitingRecordTable(Base):
|
|
"""Persistent backing for orchestrator agents in WAITING_LONG state.
|
|
|
|
Previously kept only in `AgentOrchestrator._waiting_records` (in-memory),
|
|
so an orchestrator restart stranded every waiting agent permanently.
|
|
"""
|
|
|
|
__tablename__ = "waiting_records"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
agent_id: Mapped[str] = mapped_column(
|
|
String(64), nullable=False, unique=True, index=True
|
|
)
|
|
task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
waiting_for: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
waiting_since: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False
|
|
)
|
|
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
__table_args__ = (Index("ix_waiting_records_waiting_for", "waiting_for"),)
|
|
|
|
|
|
class RespawnTrackerTable(Base):
|
|
"""Persistent backing for the orchestrator's PM-respawn loop counter.
|
|
|
|
``AgentOrchestrator._pm_respawn_tracker`` is the circuit breaker against
|
|
respawning the same PM on the same task forever. Kept only in memory it
|
|
reset to ``count=1`` on every orchestrator restart, re-burning the whole
|
|
strike threshold against a still-wedged task. This mirror survives a
|
|
restart. Default-on; inert when empty (degrades to in-memory-only).
|
|
|
|
``task_id`` is deliberately NOT a FK to ``tasks``: the startup loader
|
|
validates each row against live tasks (skipping terminal/missing ones), so
|
|
a stale counter can never resurrect against a fixed/deleted task and the
|
|
deletion authority stays in one explicit, tested place.
|
|
"""
|
|
|
|
__tablename__ = "respawn_tracker"
|
|
|
|
agent_slug: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
task_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
last_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
last_check: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False
|
|
)
|
|
tracing_resets: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
revisit_resets: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
notified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
__table_args__ = (Index("ix_respawn_tracker_last_check", "last_check"),)
|
|
|
|
|
|
# =============================================================================
|
|
# AUDIT LOG
|
|
# =============================================================================
|
|
|
|
|
|
class AuditLogTable(Base):
|
|
"""Durable audit events for compliance and the Auditor agent.
|
|
|
|
Replaces log-only audit. Dot-separated event_type (task.claimed,
|
|
session.closed, project.deleted, etc.) + JSON details.
|
|
"""
|
|
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
|
agent_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
target_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
target_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
|
|
# JSONB (not generic JSON) so the comparator exposes `.astext` —
|
|
# `AuditService.has_recent_tracing_gap` filters
|
|
# `details->>'reason' == 'tracing_gap'`, which the generic JSON Comparator
|
|
# doesn't support (raises AttributeError). JSONB also supports GIN
|
|
# indexing for future audit queries.
|
|
details: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
|
timestamp: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_audit_log_agent_timestamp", "agent_id", "timestamp"),
|
|
Index("ix_audit_log_target", "target_type", "target_id"),
|
|
# Powers the observability cycle-time / rework reconstruction: per-task
|
|
# transition journeys are read by (target_id, event_type) ordered by time.
|
|
Index("ix_audit_log_target_event_ts", "target_id", "event_type", "timestamp"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PROVIDER ROUTING TABLES
|
|
# =============================================================================
|
|
|
|
|
|
class ProviderConfigTable(Base):
|
|
"""SQLAlchemy table for model-provider connections.
|
|
|
|
One row per "logical provider" — e.g., "Anthropic (default)" (a
|
|
pointer-only row with no secret, served by the mounted ~/.claude auth)
|
|
or "Ollama Cloud Kimi" (holds an encrypted API key and a base URL).
|
|
`ModelAssignmentTable` rows reference these via `provider_config_id`.
|
|
"""
|
|
|
|
__tablename__ = "provider_configs"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
name: Mapped[str] = mapped_column(
|
|
String(100), unique=True, nullable=False, index=True
|
|
)
|
|
type: Mapped[ModelProvider] = mapped_column(
|
|
_str_enum(ModelProvider), nullable=False
|
|
)
|
|
# `base_url = NULL` → Anthropic-default path: no ANTHROPIC_BASE_URL
|
|
# injection, Claude Code inside the container uses its mounted
|
|
# ~/.claude credentials. Non-null routes to that endpoint instead.
|
|
base_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Fernet-encrypted auth token (Ollama API key for ollama_cloud rows;
|
|
# NULL for anthropic rows — their auth lives in the mounted directory).
|
|
auth_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
__table_args__ = (Index("ix_provider_configs_enabled", "enabled"),)
|
|
|
|
|
|
class SystemSettingTable(Base):
|
|
"""Key-value store for runtime-editable, panel-tunable system settings.
|
|
|
|
Operator-tunable values that must persist across restarts and be editable
|
|
from the panel (first user: ``transcript_retention_days``). One row per key;
|
|
the value is stored as text and parsed by the reader. Code defaults in
|
|
``roboco.config`` are the fallback when a key has no row yet.
|
|
"""
|
|
|
|
__tablename__ = "system_settings"
|
|
|
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
)
|
|
|
|
|
|
class CompanyGoalsTable(Base):
|
|
"""Singleton company charter — north star, objectives, operating policy.
|
|
|
|
Exactly one row (the all-zeros singleton id). The CEO owns it (writes are
|
|
CEO-only via the API); it is injected compactly into every agent's
|
|
``context_briefing`` so all work is goal-aware.
|
|
"""
|
|
|
|
__tablename__ = "company_goals"
|
|
|
|
id: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
north_star: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
objectives: Mapped[list[dict[str, Any]]] = mapped_column(
|
|
JSON, nullable=False, default=list
|
|
)
|
|
constraints: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
operating_policy: Mapped[dict[str, Any]] = mapped_column(
|
|
JSON, nullable=False, default=dict
|
|
)
|
|
# CEO-authored brand-voice sample/direction — a first-class, long charter
|
|
# field (like north_star), not folded into the catch-all operating_policy
|
|
# JSON blob, so it gets the same discoverability. Feeds XEngine._voice_guide
|
|
# and the Head of Marketing's briefing; empty until the CEO sets it.
|
|
brand_voice: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
)
|
|
updated_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
|
|
|
|
class ModelAssignmentTable(Base):
|
|
"""SQLAlchemy table for (scope, provider, model) routing rows.
|
|
|
|
Precedence at spawn time (implemented in `ModelRoutingService`):
|
|
AGENT_SLUG > ROLE > GLOBAL
|
|
with a legacy fallback to `ROLE_MODEL_MAP` when no row applies.
|
|
"""
|
|
|
|
__tablename__ = "model_assignments"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
scope: Mapped[AssignmentScope] = mapped_column(
|
|
_str_enum(AssignmentScope), nullable=False
|
|
)
|
|
# NULL when scope = 'global'; role name for 'role'; agent slug for 'agent_slug'.
|
|
scope_value: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
provider_config_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("provider_configs.id", ondelete="RESTRICT"),
|
|
nullable=False,
|
|
)
|
|
# Raw Claude Code `--model` identifier (`claude-opus-4-7`,
|
|
# `kimi-k2.6:cloud`, etc.). The orchestrator's CLI-translation only
|
|
# fires for anthropic providers; non-anthropic values pass through.
|
|
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
provider: Mapped["ProviderConfigTable"] = relationship(
|
|
"ProviderConfigTable", lazy="joined"
|
|
)
|
|
|
|
__table_args__ = (
|
|
# NULLS NOT DISTINCT so the global row (scope_value=NULL) can't be
|
|
# duplicated. Requires PostgreSQL 15+ (roboco runs on pgvector 16).
|
|
Index(
|
|
"ux_model_assignments_scope_key",
|
|
"scope",
|
|
"scope_value",
|
|
unique=True,
|
|
postgresql_nulls_not_distinct=True,
|
|
),
|
|
Index("ix_model_assignments_provider", "provider_config_id"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# GATEWAY TRIGGER TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class GatewayTriggerTable(Base):
|
|
"""Records every dispatcher spawn-decision (spawn / queue / drop) for
|
|
observability and gateway-tuning.
|
|
"""
|
|
|
|
__tablename__ = "gateway_triggers"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
trigger_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
trigger_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
|
task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
target_role: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
decision: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
decision_reason: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_gateway_triggers_task_id", "task_id"),
|
|
Index("ix_gateway_triggers_created_at", "created_at"),
|
|
Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# TOKEN USAGE TABLES
|
|
# =============================================================================
|
|
|
|
|
|
class AgentSpawnSessionTable(Base):
|
|
"""Records each agent container spawn lifecycle.
|
|
|
|
Opened when the orchestrator successfully starts a container; closed
|
|
(ended_at set) when stop_agent() finishes. Final token counts are
|
|
accumulated from the agent SDK's /usage/status endpoint.
|
|
"""
|
|
|
|
__tablename__ = "agent_spawn_sessions"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
agent_slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
team: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
task_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
)
|
|
ended_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# BIGINT — token counts can exceed INT32 for long sessions
|
|
tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_cache_read: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
tokens_cache_write: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
# LLM iterations (unique assistant messages) + tool invocations for this
|
|
# stint, captured at finalize from the SDK /usage/status (turns has a
|
|
# transcript fallback). Default 0 — historical/Grok rows read 0 ("n/a").
|
|
turns: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tool_calls: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
exit_reason: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
estimated_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
# Relationship to snapshots (backref for convenience)
|
|
snapshots: Mapped[list["TokenUsageSnapshotTable"]] = relationship(
|
|
"TokenUsageSnapshotTable",
|
|
back_populates="session",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_agent_spawn_sessions_agent_slug", "agent_slug"),
|
|
Index("ix_agent_spawn_sessions_started_at", "started_at"),
|
|
Index("ix_agent_spawn_sessions_ended_at", "ended_at"),
|
|
Index("ix_agent_spawn_sessions_team", "team"),
|
|
)
|
|
|
|
|
|
class TokenUsageSnapshotTable(Base):
|
|
"""Periodic (every ~60 s) snapshot of cumulative token usage for an
|
|
active agent_spawn_session.
|
|
|
|
The sweeper inserts one row per active agent per sweep cycle when
|
|
token counts are non-zero. Snapshots allow tracking how token usage
|
|
grows over session lifetime.
|
|
"""
|
|
|
|
__tablename__ = "token_usage_snapshots"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
agent_spawn_session_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agent_spawn_sessions.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
snapshotted_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
)
|
|
tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_cache_read: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
tokens_cache_write: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
|
|
session: Mapped["AgentSpawnSessionTable"] = relationship(
|
|
"AgentSpawnSessionTable", back_populates="snapshots"
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_token_usage_snapshots_session_id", "agent_spawn_session_id"),
|
|
Index("ix_token_usage_snapshots_snapshotted_at", "snapshotted_at"),
|
|
)
|
|
|
|
|
|
class DailyUsageRollupTable(Base):
|
|
"""Pre-aggregated daily token usage per (date, agent_slug, team, model).
|
|
|
|
Populated by the orchestrator sweeper via an upsert query over
|
|
closed agent_spawn_sessions. Unique constraint on the natural key
|
|
enables ON CONFLICT DO UPDATE so the sweep is idempotent.
|
|
"""
|
|
|
|
__tablename__ = "daily_usage_rollups"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
date: Mapped[Any] = mapped_column(Date, nullable=False) # datetime.date
|
|
agent_slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
team: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
tokens_cache_read: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
tokens_cache_write: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
total_cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
session_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"date",
|
|
"agent_slug",
|
|
"team",
|
|
"model",
|
|
name="uq_daily_rollup_date_agent_team_model",
|
|
),
|
|
Index("ix_daily_rollups_date", "date"),
|
|
Index("ix_daily_rollups_agent_slug", "agent_slug"),
|
|
)
|
|
|
|
|
|
class MemberPerformanceDailyTable(Base):
|
|
"""Pre-aggregated daily per-member performance (the granular scorecard rollup).
|
|
|
|
One row per (date, member_kind, agent_slug), populated by the orchestrator
|
|
sweeper from agent_spawn_sessions + audit_log. The CEO is a first-class
|
|
``member_kind='ceo'`` row with ``agent_slug=''`` — a distinct natural-key
|
|
tuple from every ``member_kind='agent'`` row, so it never collides.
|
|
Overwrite-upsert on the natural key makes the sweep idempotent. All counters
|
|
default 0.
|
|
"""
|
|
|
|
__tablename__ = "member_performance_daily"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
date: Mapped[Any] = mapped_column(Date, nullable=False) # datetime.date
|
|
member_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
agent_slug: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
team: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
role: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
tasks_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
tasks_first_pass: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
revisions_caused: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
revisions_received: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
active_runtime_seconds: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
turns: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
tool_calls: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
ceo_approval_dwell_seconds: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
ceo_unblock_dwell_seconds: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=0
|
|
)
|
|
godmode_actions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# The four CEO-approved extras (+ blocked_seconds).
|
|
qa_reviews_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
qa_reviews_passed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
escalations: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
blocked_others: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
idle_seconds: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
blocked_seconds: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"date", "member_kind", "agent_slug", name="uq_member_perf_day"
|
|
),
|
|
Index("ix_member_perf_date", "date"),
|
|
Index("ix_member_perf_agent_slug", "agent_slug"),
|
|
Index("ix_member_perf_team", "team"),
|
|
Index("ix_member_perf_kind", "member_kind"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PROMPTER TABLES
|
|
# =============================================================================
|
|
|
|
|
|
class PrompterSessionTable(Base):
|
|
"""A Prompter conversation session owned by an agent."""
|
|
|
|
__tablename__ = "prompter_sessions"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
agent_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
status: Mapped[str] = mapped_column(
|
|
Enum(
|
|
"active",
|
|
"draft_ready",
|
|
"confirmed",
|
|
"abandoned",
|
|
name="promptersessionstatus",
|
|
),
|
|
nullable=False,
|
|
default="active",
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
messages: Mapped[list["PrompterMessageTable"]] = relationship(
|
|
"PrompterMessageTable",
|
|
back_populates="session",
|
|
order_by="PrompterMessageTable.created_at",
|
|
cascade="all, delete-orphan",
|
|
lazy="select",
|
|
)
|
|
drafts: Mapped[list["TaskDraftTable"]] = relationship(
|
|
"TaskDraftTable",
|
|
back_populates="session",
|
|
cascade="all, delete-orphan",
|
|
lazy="select",
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_prompter_sessions_agent_id", "agent_id"),
|
|
Index("ix_prompter_sessions_status", "status"),
|
|
)
|
|
|
|
|
|
class PrompterMessageTable(Base):
|
|
"""A single message turn within a Prompter conversation session."""
|
|
|
|
__tablename__ = "prompter_messages"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
session_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
role: Mapped[str] = mapped_column(
|
|
Enum("user", "assistant", "system", name="promptermessagerole"),
|
|
nullable=False,
|
|
)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
# Relationships
|
|
session: Mapped["PrompterSessionTable"] = relationship(
|
|
"PrompterSessionTable", back_populates="messages"
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_prompter_messages_session_id", "session_id"),
|
|
Index("ix_prompter_messages_session_created", "session_id", "created_at"),
|
|
)
|
|
|
|
|
|
class TaskDraftTable(Base):
|
|
"""A structured task draft extracted from a Prompter conversation."""
|
|
|
|
__tablename__ = "task_drafts"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
session_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
draft_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
confirmed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
task_id: Mapped[UUID | None] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tasks.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
session: Mapped["PrompterSessionTable"] = relationship(
|
|
"PrompterSessionTable", back_populates="drafts"
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_task_drafts_session_id", "session_id"),
|
|
Index("ix_task_drafts_task_id", "task_id"),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PROJECT CONVENTIONS CACHE TABLE
|
|
# =============================================================================
|
|
|
|
|
|
class ProjectConventionsCacheTable(Base):
|
|
"""Cached effective conventions map, keyed by (project, commit SHA).
|
|
|
|
The effective map (auto-derived defaults overlaid by the committed
|
|
``.roboco/conventions.yml``) is re-parsed only when HEAD moves; every
|
|
consumer reads this cache. ``status`` records how the file resolved at that
|
|
SHA: ``ok`` | ``degraded`` (unparseable, fell back) | ``missing``.
|
|
"""
|
|
|
|
__tablename__ = "project_conventions_cache"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
project_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
commit_sha: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
effective_map: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
derived_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"project_id", "commit_sha", name="uq_project_conventions_cache_sha"
|
|
),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PROJECT CONVENTION FINDINGS TABLE (violations feed)
|
|
# =============================================================================
|
|
|
|
|
|
class ProjectConventionFindingTable(Base):
|
|
"""A persisted architectural-conventions finding for the violations feed.
|
|
|
|
Records the latest validator findings per task (re-recorded on each check —
|
|
``ConventionsService.record_findings`` replaces a task's rows), so the panel
|
|
can show recent block/warn violations across a project and drift stays
|
|
visible. Written best-effort from the i_am_done gate in its own committed
|
|
session, so a finding that *blocked* the submit is still captured.
|
|
"""
|
|
|
|
__tablename__ = "project_convention_findings"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
project_id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
task_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
file: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
line: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
rule: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
level: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
kind: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
detected_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# CLOUD AUTH USERS TABLE (FastAPI Users schema)
|
|
# =============================================================================
|
|
|
|
|
|
class UserTable(Base):
|
|
"""The single seeded CEO login user for cloud auth (default off).
|
|
|
|
Field set matches the FastAPI Users ``UserProtocol`` (structural typing —
|
|
no mixin inheritance needed): id/email/hashed_password/is_active/
|
|
is_superuser/is_verified. No registration router is mounted; the one row
|
|
is idempotently upserted at startup from cloud_auth_email/password
|
|
(``roboco.api.auth.seed.ensure_seed_user``).
|
|
|
|
The ``TYPE_CHECKING`` split (bare types vs. ``Mapped[...]``) mirrors
|
|
``fastapi_users_db_sqlalchemy``'s own base table: mypy's structural
|
|
Protocol check needs the plain-type view to accept this class as a
|
|
``UserProtocol``; the ``else`` branch is what SQLAlchemy actually builds
|
|
at runtime.
|
|
"""
|
|
|
|
__tablename__ = "users"
|
|
|
|
if TYPE_CHECKING:
|
|
id: PyUUID
|
|
email: str
|
|
hashed_password: str
|
|
is_active: bool
|
|
is_superuser: bool
|
|
is_verified: bool
|
|
else:
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
email: Mapped[str] = mapped_column(
|
|
String(320), unique=True, index=True, nullable=False
|
|
)
|
|
hashed_password: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
is_superuser: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, nullable=False
|
|
)
|
|
is_verified: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, nullable=False
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# X (TWITTER) ACCOUNT TABLES
|
|
# =============================================================================
|
|
|
|
|
|
class XCredentialsTable(Base):
|
|
"""Singleton row holding the Fernet-encrypted X OAuth 1.0a user-context
|
|
secrets (mirrors ``ProviderConfigTable``'s encrypted-token column). At most
|
|
one row ever exists; ``XCredentialsService`` upserts it. Never read by an
|
|
agent — decrypted only server-side, by ``x_client``."""
|
|
|
|
__tablename__ = "x_credentials"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
api_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
api_secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
access_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
access_token_secret_encrypted: Mapped[str | None] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
|
|
class XSeenMentionTable(Base):
|
|
"""Dedup ledger for the mentions poll — one row per mention id the engine
|
|
has ever turned into a held reply proposal (or decided to skip). Never
|
|
pruned by task terminal state, so a rejected/completed proposal's mention
|
|
is never re-proposed on a later cycle."""
|
|
|
|
__tablename__ = "x_seen_mentions"
|
|
|
|
mention_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
|
|
class XSeenFeatureTable(Base):
|
|
"""Dedup ledger for feature-spotlight drafts — one row per feature slug the
|
|
engine has ever turned into a held spotlight. Prevents re-covering the same
|
|
shipped capability on a later cycle. Never pruned by task terminal state."""
|
|
|
|
__tablename__ = "x_seen_features"
|
|
|
|
feature_slug: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
|
|
class VaultSeenNoteTable(Base):
|
|
"""Dedup ledger for the vault-intake watcher — one row per (vault-relative
|
|
path, content hash) pair already turned into a held draft. Unique on both
|
|
columns together (not just path): an unchanged note never reprocesses, but
|
|
an edited note (new hash) is eligible again. Mirrors XSeenMentionTable."""
|
|
|
|
__tablename__ = "vault_seen_notes"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
note_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
processed_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"note_path", "content_hash", name="uq_vault_seen_notes_path_hash"
|
|
),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# TIKTOK ACCOUNT TABLES
|
|
# =============================================================================
|
|
|
|
|
|
class TikTokCredentialsTable(Base):
|
|
"""Singleton row holding the Fernet-encrypted TikTok OAuth2 secrets
|
|
(mirrors ``XCredentialsTable``). Unlike X's OAuth 1.0a secrets,
|
|
access_token/refresh_token rotate on refresh — ``TikTokCredentialsService.
|
|
update_tokens`` is the narrower write for that path; client_key/
|
|
client_secret never change post-setup. Never read by an agent — decrypted
|
|
only server-side, by ``tiktok_client``."""
|
|
|
|
__tablename__ = "tiktok_credentials"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
client_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
client_secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
access_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
refresh_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# RAG INDEX DEAD-LETTER
|
|
# =============================================================================
|
|
|
|
|
|
class RagIndexFailureTable(Base):
|
|
"""Dead-letter for fire-and-forget RAG index writes that dropped on failure.
|
|
|
|
``_schedule_rag_index`` (journal) and ``_extract_completion_learnings``
|
|
(task) index off the critical path: an embedder 429 after retries was
|
|
swallowed + logged, leaving the entry invisible to ``optimal.search`` /
|
|
``similar_memory`` though it lived in the DB. A failure here is persisted
|
|
instead of dropped; a startup janitor reclaims due rows with backoff — on
|
|
success the row is deleted, on failure ``attempts`` bumps and
|
|
``next_retry_at`` advances. Best-effort: a failed index never blocks the
|
|
caller's commit.
|
|
"""
|
|
|
|
__tablename__ = "rag_index_failures"
|
|
|
|
id: Mapped[UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
)
|
|
doc_source: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
last_error: Mapped[str] = mapped_column(Text, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
next_retry_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, index=True
|
|
)
|