mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
2356 lines
84 KiB
Python
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,
|
|
)
|