Files
roboco/roboco/db/tables.py
T
889f3689e7 MegaTask (#248)
* feat(batch): batch_id + collision descriptor columns

Sequenced batch intake ("Mega task") foundation: tasks.batch_id (indexed)
groups a batch of top-level tasks created together; intends_to_touch (text[]),
adds_migration and touches_shared (bool, NOT NULL default false) are the
per-task collision surface the SequencingService will read to wire dependency
waves. Mirrored on the Task model + TaskCreateRequest and wired through
TaskService.create. Migration 046 (real upgrade->downgrade->upgrade verified
vs a throwaway pgvector PG); a non-batch task declares no surface (defaults).

Task 1 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): flag + draft collision descriptors

Default-off ROBOCO_BATCH_INTAKE_ENABLED (config + FEATURE_FLAGS + panel card);
the propose_draft tool doc + the TS DraftProposal gain the per-task collision
surface intends_to_touch / adds_migration / touches_shared. The draft is a loose
dict so the descriptors ride it through the relay intact (test asserts the
forwarded payload); the analyzer (Task 3) reads them to wire dependency waves.

Task 2 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): deterministic collision-sequencing analyzer

SequencingService.analyze turns a batch's per-task collision surfaces into a
dependency DAG + execution waves — correctness in CODE, not agent judgment.
Rules in order: file overlap serializes (more-important first), migrations form
a serial chain (no concurrent Alembic heads), touches_shared runs last, cell
contention warns (never serializes); then dedupe, existence + cycle check, and
Kahn topological layering. Pure (no DB/services); SequencingError on a cycle or
out-of-range edge.

Golden test reproduces the CEO's hand-sequenced 4 waves of the 11-item
guard-core-app batch (the effort that deadlocked the Main PM): S6 alone last,
the R1/R3/R4 migration chain, R2/R3/S8 serialized on the shared threat service,
S1/S2/S7 in one parallel wave.

Task 3 of the 0.11.0 sequenced-batch-intake plan.

* chore(batch): brand the user-facing surfaces "MegaTask"

The user-facing name is MegaTask: the feature-flag label is "MegaTask intake",
the panel flag-card and the config description lead with MegaTask. Internal
names stay technical (batch_intake_enabled, batch_id, SequencingService).

* chore(batch): drop the feature flag — MegaTask is a core intake scope

MegaTask is additive and opt-in by its own nature (the Prompter proposes a
batch only when the CEO asks for several tasks; single-task intake is
unchanged), so there is no risk surface a flag protects — 'don't create a
MegaTask' is the off switch. Remove batch_intake_enabled from config, the
FEATURE_FLAGS registry, the panel flag card, and its tests. MegaTask will be
a third scope option in the Intake modal (single-cell / multi-project /
MegaTask), not a toggle.

* feat(batch): MegaTask identity predicate + orchestrator branchless recognition

The single source of truth for the umbrella's exemptions: pure
is_batch_umbrella / is_batch_root_subtask / is_branchless_coordination
(foundation/policy/batch.py) — an umbrella has a batch_id and is top-level; a
root-subtask shares the batch_id but is parented. The orchestrator's
_is_coordination_task now consults is_branchless_coordination, so a MegaTask
umbrella is recognized as doing no git of its own (git-exempt at spawn-readiness
/ stuck-detection) exactly like a product fan-out root. Non-batch behavior is
identical (the predicate reduces to the old no-project+product check; the
orchestrator coordination suite stays green), and the umbrella branch is inert
until the create path exists.

First slice of the MegaTask umbrella enforcement (branchless guard).

* feat(batch): branchless umbrella guard across the git-exemption sites

A MegaTask umbrella does no git of its own — every git-exemption site in
TaskService now consults the shared is_branchless_coordination predicate
instead of an inline product-only check, so the umbrella's exemptions
cannot drift between sites:

- the claimed->in_progress branch gate (GitContext.is_coordination) lets
  an unbranched umbrella reach in_progress and delegate;
- _ensure_branch_for_task short-circuits an umbrella to "" instead of the
  misconfigured raise (the claim path ignores the return, treating it as
  branchless);
- CEO-reject routing sends a rejected umbrella to the Main PM in PENDING
  (needs_revision is developer-claim-only and would deadlock it).

Covers both shapes via the predicate (product fan-out root OR umbrella);
a batch root-subtask keeps its own branch/PR. Adds orchestrator
recognition tests for the umbrella plus claim/branch/reject integration
tests.

* feat(batch): umbrella assembles no PR; completes branchless

submit_root now hard-rejects a MegaTask umbrella up front (a preflight
that also folds in the unknown-role refusal to stay within the
return-count budget): the umbrella spans many projects with no single
master, so each root-subtask opens and is reviewed on its own PR — the
umbrella never enters the in-path review gate. The Main PM completes it
directly once every root-subtask is terminal.

Umbrella completion needs no new code: it is branchless (no branch_name),
so _main_pm_complete_guard already accepts it from in_progress, checks
all_subtasks_terminal, and main_pm_complete walks it to awaiting_pm_review
and escalates to the CEO with no PR creation — exactly the product
fan-out root path. Adds the submit_root-reject and umbrella-completion
gateway tests; pins batch_id=None on the normal-root submit_root test
(a MagicMock auto-attr would otherwise read as an umbrella).

* feat(batch): MegaTask create path — umbrella + sequenced root-subtasks

PrompterService.confirm_live_batch turns N confirmed drafts into a real
MegaTask: it builds each draft's collision surface, runs the pure
SequencingService to get conflict-free waves, creates the branchless
umbrella (batch_id, no project/product), then one root-subtask per draft
(own project, parent=umbrella, sequence=wave index, descriptors), and
wires the analyzer's edges through add_dependency so the existing
dependency-gate runs the waves in order. The route picks the start path
like a single confirm: 'board' holds the root-subtasks in BACKLOG for the
batch review; 'main_pm' creates them PENDING so wave 0 dispatches at once.

create_task_from_draft gains a BatchPlacement (parent/batch/sequence/
team_override) and forwards the collision descriptors; the exactly-one-
target rule (here and the TaskService.create invariant) is relaxed for an
umbrella, which legitimately targets neither. New route
POST /live/{session}/confirm-batch + BatchConfirmRequest mirror the single
confirm. Adds the structural-invariant + board-hold + empty-batch tests.

* feat(batch): release MegaTask root-subtasks on CEO approval; board awareness

The board route holds a MegaTask's root-subtasks in BACKLOG so the work
waits for the batch review. approve_and_start (CEO gate #1, board->Main PM)
now releases them via _activate_batch_root_subtasks: each held child flips
BACKLOG -> PENDING + team=main_pm so the dependency-gate dispatches wave 0.
No-op for a non-umbrella; idempotent (children past BACKLOG untouched).

The Product Owner and Head of Marketing identity prompts gain a MegaTask
section so they review the whole batch + wave plan and adjust scope before
sign-off (they review drafts; the umbrella is their unit). Also extracts
the create() target invariant into _require_target_or_umbrella to keep the
method under the complexity gate after the umbrella exemption. Adds the
umbrella-approval activation test.

* feat(batch): multi-project intake scope for MegaTask

A MegaTask spans several possibly-unrelated repos, so the intake chat can
now be scoped to an explicit project list (not just one project or one
product). StartLiveRequest gains project_ids; /live/start threads it
through start/spawn_intake_session -> _spawn_intake_container ->
_clone_intake_scope. The multi-repo clone machinery already existed for
products; _intake_scope_slugs now also resolves an explicit project_ids
set (split into _slugs_for_project_ids / _slugs_for_product), cloning each
repo with the first as the primary cwd and the siblings readable. Scope
validation is now 'exactly one of project_slug / product_id / project_ids'
via the shared _require_one_intake_scope. Adds scope-resolution, spawn,
and route tests for the MegaTask path.

* feat(batch): propose_batch intake tool (MegaTask multi-draft hand-off)

The intake agent can now hand the panel a whole MegaTask in one tool call.
Both intake paths gain propose_batch alongside propose_draft:
- Claude (intake_driver): a propose_batch tool registered on the in-SDK
  MCP server + allowlisted; the driver intercepts the ToolUseBlock and
  emits ONE StreamChunk(kind="batch") carrying {drafts:[...], title}.
- grok (intake_server): a propose_batch tool that POSTs a "batch" relay
  event via the shared _post_event helper (post_draft/post_batch).

A batch carries N drafts, each the propose_draft shape PLUS its own
project_id (a MegaTask spans unrelated repos) and collision surface so the
analyzer sequences the waves. The prompter prompt documents the MegaTask
scope + when to call propose_batch. Adds Claude-normalize and grok-relay
tests for the batch path.

* feat(batch): MegaTask intake panel — third scope, batch review, waves

The panel now drives a MegaTask end to end. The intake modal gains a
third scope, 'MegaTask', beside Single cell and Board-led: a multi-project
checklist (a MegaTask spans several possibly-unrelated repos), validated
to at least two. start() sends project_ids; use-prompter accumulates the
agent's single propose_batch hand-off as a 'batch' SSE event into a
BatchProposal and lands in a new batch_preview state.

A new BatchReviewCard lists every proposed task with its target project +
collision-surface badges (migration / shared) and offers one start path
for the whole batch — Board review & Start or Approve & Start — wired to
confirmBatch → POST /confirm-batch. The success card shows the sequenced
result: N tasks in M waves (+ any advisory notes). prompter.ts gains the
DraftScale 'megatask' + the BatchConfirm payload/result types; the SSE
client allows the 'batch' kind. Panel typecheck + lint + 113 tests green.

* docs(batch): MegaTask across changelog, CLAUDE.md, site, and RAG

The four documentation obligations for the MegaTask feature:
- CHANGELOG: an Unreleased entry covering the umbrella model, sequencing,
  multi-project intake, propose_batch, and the create/approval path.
- CLAUDE.md: a MegaTask section (identity predicate, umbrella/root-subtask
  hierarchy, sequencing rules, intake + create path, board activation).
- Published site: a user-facing company/megatask.md (scopes, waves, the
  umbrella, the two start buttons) + nav entry; a pointer added to the
  intake chapter of the Tour.
- RAG corpus: workflows/megatask.md so the Main PM (and any agent) can
  retrieve the umbrella's branchless / no-PR / completion rules at runtime.

The runtime concurrent-migration guard is intentionally NOT added: the
analyzer already chains migration-adders into dependencies and the
dependency-gate serializes them, so a separate guard would be dead code.

* feat(batch): batch_id guardrail + wave preview + batch_id on TaskResponse

Guardrail (CEO): a batch_id is denied on any task that is not a well-formed
MegaTask member. is_valid_batch_shape permits batch_id only on an umbrella
(no parent → must target neither project nor product) or a root-subtask
(has a parent → exactly one target); TaskService.create enforces it AND
verifies a root-subtask's parent is the batch umbrella (same batch_id,
top-level). This closes a latent hole: is_batch_umbrella is true for a
batch_id + no-parent task even with a project, so a stray batch_id could
have spoofed the branchless branch-gate / no-PR exemption. (The public
task API never exposed batch_id for write; this guards the service layer.)

Wave preview: PrompterService.preview_batch + POST .../preview-batch
compute a MegaTask's waves from the proposed drafts WITHOUT creating
anything, so the panel can show the sequencing before confirm. Extracted
_sequence_drafts as the single source shared by preview and confirm, so
the previewed waves are exactly the ones wired.

TaskResponse now carries batch_id so the panel can badge the umbrella.

* feat(batch): MegaTask review — project editor, wave preview, persistence, badge

Closes the panel gaps in the MegaTask review experience:
- Per-task project editor: each proposed task gets an inline project
  Select (updateBatchDraftProject), so a task the agent put in the wrong
  or no repo can be fixed before launch — not only by re-chatting. Launch
  stays blocked until every task has a project.
- Wave preview: on a batch proposal the panel fetches POST .../preview-batch
  (no task created) and shows the conflict-free wave plan, so the human
  reviews the sequencing before confirming.
- Refresh durability: the MegaTask review (batch + waves + projectIds) is
  persisted, so a browser reload mid-review restores it like a single draft.
- MegaTask badge: TaskResponse exposes batch_id, the panel Task type
  carries it, and the task table badges the umbrella row 'MegaTask'.

Panel typecheck + lint + 113 tests green.

* test(batch): stub task carries batch_id for task_to_response

task_to_response now serializes batch_id (TaskResponse field), so the
_stub_task SimpleNamespace fixture must provide it — without it the reader
hit AttributeError, failing the 8 task-schema serialization/enrichment
tests. Test-only; the real TaskTable carries the column (migration 046).

* fix(batch): close MegaTask audit gaps — completion crash, analyzer cycle, guardrails

An adversarial multi-agent audit of the feature surfaced 20 verified gaps;
this closes the backend ones.

HIGH:
- Umbrella completion crashed. escalate_to_ceo hard-required a pr_number,
  which a branchless umbrella never has, so main_pm_complete dereferenced
  None. Both pr_number gates now waive a MegaTask umbrella (escalate_to_ceo
  + the awaiting_pm_review->awaiting_ceo_approval lifecycle gate via a new
  GitContext.is_umbrella), and main_pm_complete guards a None return. The
  completion test had mocked escalate_to_ceo, hiding it — now a real
  service test covers the waiver.
- The collision analyzer could fabricate a cycle (a touches_shared +
  adds_migration draft overlapping another migration draft) and raise
  SequencingError — a bare ValueError that escaped as an opaque 500. The
  migration chain is now shared-last-aware (never contradicts rule 3), and
  _sequence_drafts translates SequencingError to a clean 400.

MEDIUM:
- Collisions are now project-scoped: two repos can't collide on a
  coincidental path or serialize independent migrations (DraftSurface
  carries project_id; rules 1/2/3 respect it).
- The batch_id guardrail ran only at create. update() + the PATCH
  null-clear path now re-assert is_valid_batch_shape, so a mutation can't
  break a member's shape and spoof the branchless exemption.
- A draft missing title/acceptance_criteria now raises ValidationError
  (was a bare KeyError -> 500).
- confirm_live_batch re-asserts every draft targets a scoped project and
  the batch spans >=2 distinct projects (project_ids added to the request).
- Route-level tests for confirm-batch / preview-batch.

LOW: strict multi-repo clone (fail loud on any unresolvable project);
malformed/empty propose_batch surfaces an error chunk (Claude) / refuses
to POST (grok) instead of silently acking; dropped malformed drafts are
counted and surfaced; stale grok intake docstrings updated.

* fix(batch): MegaTask panel + doc audit gaps

Frontend half of the audit fixes:
- The confirm payload now carries project_ids (the schema requires it), and
  the panel re-checks every task targets one of the scoped repos before
  launching, naming the offending task.
- The Review-MegaTask project picker is filtered to the scoped repos and
  the per-task validity (border + launch gate) keys off scoped membership,
  so a task can only be (re)pointed at an in-scope project — also fixing the
  case where the agent emitted a non-UUID / unknown project.
- Dropped malformed drafts are surfaced as a chat error so the human knows
  the batch shrank instead of silently confirming fewer tasks.
- Doc wording: a wave releases on the previous wave's terminal state
  (normally a merge; a cancellation releases it too), not strictly 'merged'.

* test(batch): lock the CEO's EXACT 4-wave hand-sequencing as the golden bar

The golden test asserted the constraints (S6 last, the migration chain, the
shared-threats serialization, S1/S2/S7 parallel) but not the full wave
partition. The bar for MegaTask is 'reproduce my exact waves or it's not
done', so assert the exact 4-wave partition the analyzer produces for the
guard-core-app batch:
  wave 1: R1 R2 S1 S2 S3 S5 S7  ·  wave 2: R3  ·  wave 3: R4 S8  ·  wave 4: S6
Confirmed unchanged by the audit's analyzer fixes (no migration is shared;
single project).

* fix(batch): tolerate a stub task in assert_batch_shape_intact

The batch-shape re-validation read task.batch_id directly, but update()'s
partial-caller contract is exercised with a SimpleNamespace stub that has no
batch_id column → AttributeError. Use getattr(..., None) for batch_id and the
shape fields so the guard no-ops on any task lacking the column (a stub, or a
non-batch task) while still enforcing on a real batch member.

* fix(orchestrator): authenticate internal API self-calls with the system identity

The dispatcher httpx clients were built without an agent identity, so the
orchestrator's self-PATCHes to /api/tasks/{id} (auto-block, auto-resume,
auto-recover, SLA annotation) were rejected 401 "Missing X-Agent-ID" and
silently no-op'd. The auto-resume that lifts a PM's paused parent could never
write, so paused/blocked parents stayed wedged and stranded their dependents
(the fe-pm/be-pm respawn churn seen in prod).

Header propagation was inconsistent across the separate AsyncClient call-sites:
only the main dispatch client carried the system identity; the readiness and
sweep clients did not. Hoist the identity into a shared _SYSTEM_API_HEADERS
constant and apply it to every API-facing dispatcher client. The system role
holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
path those write routes use. The external provider-recovery probe client is
intentionally left untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 01:15:57 +02:00

2356 lines
84 KiB
Python

"""
SQLAlchemy Table Definitions
ORM mappings for all RoboCo data models.
"""
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID as PyUUID
from uuid import uuid4
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
Interval,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from roboco.db.base import Base
from roboco.models.a2a import A2AConversationStatus, A2AMessageKind
from roboco.models.base import (
AgentRole,
AgentStatus,
AssignmentScope,
BlockerResolverType,
ChannelType,
Complexity,
HandoffStatus,
JournalEntryType,
MessageType,
ModelProvider,
NotificationPriority,
NotificationType,
SessionStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.session import SessionScope
from roboco.models.work_session import WorkSessionStatus
# 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)
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
)
# 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
)
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"
)
# Session links (many-to-many via SessionTaskTable).
# passive_deletes=True tells SA to trust the DB's ON DELETE CASCADE and
# NOT emit `UPDATE session_tasks SET task_id=NULL` before the delete —
# the task_id column is NOT NULL, so that pre-null attempt hits an
# IntegrityError and DELETE /tasks/{id} fails with NotNullViolation.
session_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable",
back_populates="task",
lazy="select",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
# Composite indexes for common queries
Index("ix_tasks_team_status", "team", "status"),
Index("ix_tasks_assigned_status", "assigned_to", "status"),
Index("ix_tasks_created_by_status", "created_by", "status"),
Index("ix_tasks_project_status", "project_id", "status"),
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)
# 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 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 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] = 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"),
)
# =============================================================================
# CHANNEL TABLE
# =============================================================================
class ChannelTable(Base):
"""SQLAlchemy table for channels."""
__tablename__ = "channels"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
type: Mapped[ChannelType] = mapped_column(_str_enum(ChannelType), nullable=False)
# Description
description: Mapped[str | None] = mapped_column(Text, nullable=True)
topic: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control
members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
writers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
silent_observers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
allow_threads: Mapped[bool] = mapped_column(Boolean, default=True)
allow_reactions: Mapped[bool] = mapped_column(Boolean, default=True)
message_retention_days: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=90
)
max_message_length: Mapped[int] = mapped_column(Integer, default=10000)
# Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0)
group_count: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for collections to avoid N+1
groups: Mapped[list["GroupTable"]] = relationship(
"GroupTable", back_populates="channel", lazy="select"
)
# =============================================================================
# GROUP TABLE
# =============================================================================
class GroupTable(Base):
"""SQLAlchemy table for groups."""
__tablename__ = "groups"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
channel_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("channels.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Access Control
allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
hierarchy_level: Mapped[int] = mapped_column(Integer, default=4)
members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Current Session
active_session_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
# Session Configuration (stored as JSON)
default_session_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Statistics
total_sessions: Mapped[int] = mapped_column(Integer, default=0)
total_messages: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for collections to avoid N+1
channel: Mapped["ChannelTable"] = relationship(
"ChannelTable", back_populates="groups"
)
sessions: Mapped[list["SessionTable"]] = relationship(
"SessionTable", back_populates="group", lazy="select"
)
# =============================================================================
# SESSION TABLE
# =============================================================================
class SessionTable(Base):
"""SQLAlchemy table for sessions."""
__tablename__ = "sessions"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
group_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Boundaries
max_time_window: Mapped[timedelta | None] = mapped_column(
Interval, nullable=True, default=timedelta(minutes=30)
)
max_message_count: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=100
)
max_content_length: Mapped[int | None] = mapped_column(
Integer, nullable=True, default=50000
)
# Timeout
timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
# State
status: Mapped[SessionStatus] = mapped_column(
_str_enum(SessionStatus),
nullable=False,
default=SessionStatus.ACTIVE,
index=True,
)
# Scope (for smart context loading)
scope: Mapped[SessionScope] = mapped_column(
_str_enum(SessionScope), nullable=False, default=SessionScope.TASK, index=True
)
# Timestamps
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
last_activity_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0)
total_content_length: Mapped[int] = mapped_column(Integer, default=0)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships - CRITICAL: use lazy="select" for messages (sessions can have 100+)
group: Mapped["GroupTable"] = relationship("GroupTable", back_populates="sessions")
messages: Mapped[list["MessageTable"]] = relationship(
"MessageTable", back_populates="session", lazy="select"
)
# Task links (many-to-many via SessionTaskTable).
# passive_deletes mirrors the TaskTable side — the DB FK is CASCADE, so
# deleting a session should cascade-delete session_tasks rows. Without
# passive_deletes, SA tries to NULL task_id first, violating NOT NULL.
task_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable",
back_populates="session",
lazy="select",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
# Composite indexes for common queries
Index("ix_sessions_group_status", "group_id", "status"),
Index("ix_sessions_status_started", "status", "started_at"),
)
# =============================================================================
# SESSION-TASK JUNCTION TABLE
# =============================================================================
class SessionTaskTable(Base):
"""
Junction table for many-to-many Session ↔ Task relationship.
Enables PMs to create work sessions as discussion contexts for tasks.
A task can have multiple sessions (planning, review, retrospective).
A session can discuss multiple related tasks.
"""
__tablename__ = "session_tasks"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Foreign Keys (indexes defined in __table_args__)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
)
task_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
)
# Relationship Metadata
is_primary: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
) # Primary discussion session for this task
relationship_type: Mapped[str] = mapped_column(
String(50), default="discussion", nullable=False
) # "discussion", "planning", "review", "retrospective"
# Audit
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
added_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True, # Allow NULL if PM is deleted
)
# Relationships
session: Mapped["SessionTable"] = relationship(
"SessionTable", back_populates="task_links", lazy="joined"
)
task: Mapped["TaskTable"] = relationship(
"TaskTable", back_populates="session_links", lazy="joined"
)
added_by_agent: Mapped["AgentTable | None"] = relationship(
"AgentTable", lazy="select"
)
__table_args__ = (
# Each session-task pair is unique
UniqueConstraint("session_id", "task_id", name="uq_session_task"),
# Partial unique index: only one primary session per task
Index(
"ix_session_tasks_primary_per_task",
"task_id",
unique=True,
postgresql_where=(is_primary.is_(True)),
),
# Fast lookups
Index("ix_session_tasks_task_id", "task_id"),
Index("ix_session_tasks_session_id", "session_id"),
Index("ix_session_tasks_type", "relationship_type"),
)
# =============================================================================
# MESSAGE TABLE
# =============================================================================
class MessageTable(Base):
"""SQLAlchemy table for messages."""
__tablename__ = "messages"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Source & Context
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True
)
channel_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("channels.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
group_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Content
type: Mapped[MessageType] = mapped_column(
_str_enum(MessageType), nullable=False, index=True
)
content: Mapped[str] = mapped_column(Text, nullable=False)
content_length: Mapped[int] = mapped_column(Integer, nullable=False)
# Threading
is_reply: Mapped[bool] = mapped_column(Boolean, default=False)
reply_to: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
)
# Mentions
mentions: Mapped[list[UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Task Context
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
commit_ref: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Metadata
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
# Extraction metadata
confidence: Mapped[float] = mapped_column(Float, default=1.0)
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
# Edit tracking (stored as JSON)
edited_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships - use lazy="joined" for agent to avoid N+1 on message lists
agent: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
session: Mapped["SessionTable"] = relationship(
"SessionTable", back_populates="messages"
)
parent_message: Mapped["MessageTable | None"] = relationship(
"MessageTable", remote_side=[id], lazy="select"
)
__table_args__ = (
# Composite indexes for efficient queries
Index("ix_messages_channel_timestamp", "channel_id", "timestamp"),
Index("ix_messages_agent_timestamp", "agent_id", "timestamp"),
Index("ix_messages_session_timestamp", "session_id", "timestamp"),
)
# =============================================================================
# NOTIFICATION TABLE
# =============================================================================
class NotificationTable(Base):
"""SQLAlchemy table for notifications."""
__tablename__ = "notifications"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
type: Mapped[NotificationType] = mapped_column(
_str_enum(NotificationType), nullable=False, index=True
)
priority: Mapped[NotificationPriority] = mapped_column(
_str_enum(NotificationPriority),
nullable=False,
default=NotificationPriority.NORMAL,
)
# Routing
from_agent: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
)
to_agents: Mapped[list[UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), nullable=False
)
# Content
subject: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
# Acknowledgment
requires_ack: Mapped[bool] = mapped_column(Boolean, default=True)
acked_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Context
related_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
related_message_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Timing
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Read tracking
read_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Delivery tracking
delivered_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships
sender: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
related_task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
__table_args__ = (
# Composite indexes for notification queries
Index("ix_notifications_from_agent_timestamp", "from_agent", "timestamp"),
Index("ix_notifications_type_priority", "type", "priority"),
Index("ix_notifications_timestamp_priority", "timestamp", "priority"),
)
# =============================================================================
# JOURNAL TABLE
# =============================================================================
class JournalTable(Base):
"""SQLAlchemy table for journals."""
__tablename__ = "journals"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Metadata
total_entries: Mapped[int] = mapped_column(Integer, default=0)
last_entry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Summary
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Growth metrics (stored as JSON)
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships - use lazy="select" for entries collection to avoid N+1
agent: Mapped["AgentTable"] = relationship("AgentTable", lazy="joined")
entries: Mapped[list["JournalEntryTable"]] = relationship(
"JournalEntryTable", back_populates="journal", lazy="select"
)
class JournalEntryTable(Base):
"""SQLAlchemy table for journal entries."""
__tablename__ = "journal_entries"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
journal_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("journals.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Content
type: Mapped[JournalEntryType] = mapped_column(
_str_enum(JournalEntryType), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# Context
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
session_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True,
)
# Metadata
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
# Sentiment
sentiment: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Visibility
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
journal: Mapped["JournalTable"] = relationship(
"JournalTable", back_populates="entries"
)
__table_args__ = (
# Composite indexes for journal entry queries
Index("ix_journal_entries_journal_timestamp", "journal_id", "timestamp"),
Index("ix_journal_entries_journal_type", "journal_id", "type"),
Index("ix_journal_entries_task_id", "task_id"),
)
# =============================================================================
# HANDOFF TABLE
# =============================================================================
class HandoffTable(Base):
"""
SQLAlchemy table for structured documentation handoffs.
STATUS: RESERVED FOR FUTURE USE
===============================
This table exists in the schema but has no service layer or API yet.
Current Implementation:
Handoffs use the simpler `dev_notes` + `handoff_summary` parameters
in 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,
)
# Threading
response_to_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("a2a_messages.id", ondelete="SET NULL"),
nullable=True,
)
requires_response: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# Read tracking
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
# Edit support
edited_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Relationships
conversation: Mapped["A2AConversationTable"] = relationship(
"A2AConversationTable", back_populates="messages"
)
response_to: Mapped["A2AMessageTable | None"] = relationship(
"A2AMessageTable", remote_side=[id], lazy="select"
)
__table_args__ = (
# Composite indexes for message queries
Index("ix_a2a_msg_conv_created", "conversation_id", "created_at"),
Index("ix_a2a_msg_from_created", "from_agent", "created_at"),
# Partial index for pending responses
Index(
"ix_a2a_msg_pending",
"conversation_id",
postgresql_where=(requires_response.is_(True)),
),
)
# =============================================================================
# ORCHESTRATOR WAITING RECORDS
# =============================================================================
class WaitingRecordTable(Base):
"""Persistent backing for orchestrator agents in WAITING_LONG state.
Previously kept only in `AgentOrchestrator._waiting_records` (in-memory),
so an orchestrator restart stranded every waiting agent permanently.
"""
__tablename__ = "waiting_records"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, index=True
)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
waiting_for: Mapped[str] = mapped_column(String(64), nullable=False)
waiting_since: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (Index("ix_waiting_records_waiting_for", "waiting_for"),)
# =============================================================================
# AUDIT LOG
# =============================================================================
class AuditLogTable(Base):
"""Durable audit events for compliance and the Auditor agent.
Replaces log-only audit. Dot-separated event_type (task.claimed,
session.closed, project.deleted, etc.) + JSON details.
"""
__tablename__ = "audit_log"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
agent_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
target_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
target_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
# JSONB (not generic JSON) so the comparator exposes `.astext` —
# `AuditService.has_recent_tracing_gap` filters
# `details->>'reason' == 'tracing_gap'`, which the generic JSON Comparator
# doesn't support (raises AttributeError). JSONB also supports GIN
# indexing for future audit queries.
details: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
__table_args__ = (
Index("ix_audit_log_agent_timestamp", "agent_id", "timestamp"),
Index("ix_audit_log_target", "target_type", "target_id"),
# 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
)
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
)
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"),
)
# =============================================================================
# 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,
)