feat: agent coordination system (workflows, locks, components, messages)
Adds /api/coord/* endpoints for real-time cross-session coordination: - coord_workflows: named units of work per project - coord_work_items: tasks within workflows with dependency chains - coord_session_locks: exclusive resource locks with auto-expiry (TTL) - coord_component_states: live component state per project (upsert) - coord_messages: cross-session messaging and broadcasts - /api/coord/status: cross-project snapshot endpoint Replaces PROJECT_STATE.md as the coordination layer for Claude sessions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
18
api/main.py
18
api/main.py
@@ -39,6 +39,16 @@ from api.routers import (
|
||||
gravityzone,
|
||||
)
|
||||
|
||||
# Import coordination routers
|
||||
from api.routers import (
|
||||
coord_workflows,
|
||||
coord_work_items,
|
||||
coord_locks,
|
||||
coord_components,
|
||||
coord_messages,
|
||||
coord_status,
|
||||
)
|
||||
|
||||
# Import middleware
|
||||
from api.middleware.error_handler import register_exception_handlers
|
||||
|
||||
@@ -136,6 +146,14 @@ app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin
|
||||
app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"])
|
||||
app.include_router(gravityzone.router, prefix="/api/gravityzone", tags=["GravityZone"])
|
||||
|
||||
# Agent coordination
|
||||
app.include_router(coord_workflows.router, prefix="/api/coord/workflows", tags=["Coordination"])
|
||||
app.include_router(coord_work_items.router, prefix="/api/coord/work-items", tags=["Coordination"])
|
||||
app.include_router(coord_locks.router, prefix="/api/coord/locks", tags=["Coordination"])
|
||||
app.include_router(coord_components.router, prefix="/api/coord/components", tags=["Coordination"])
|
||||
app.include_router(coord_messages.router, prefix="/api/coord/messages", tags=["Coordination"])
|
||||
app.include_router(coord_status.router, prefix="/api/coord/status", tags=["Coordination"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -5,6 +5,11 @@ This package contains all database models and their base classes.
|
||||
"""
|
||||
|
||||
from api.models.api_audit_log import ApiAuditLog
|
||||
from api.models.coord_workflow import CoordWorkflow
|
||||
from api.models.coord_work_item import CoordWorkItem
|
||||
from api.models.coord_session_lock import CoordSessionLock
|
||||
from api.models.coord_component_state import CoordComponentState
|
||||
from api.models.coord_message import CoordMessage
|
||||
from api.models.backup_log import BackupLog
|
||||
from api.models.base import Base, TimestampMixin, UUIDMixin
|
||||
from api.models.billable_time import BillableTime
|
||||
@@ -47,6 +52,11 @@ from api.models.work_item_tag import WorkItemTag
|
||||
|
||||
__all__ = [
|
||||
"ApiAuditLog",
|
||||
"CoordWorkflow",
|
||||
"CoordWorkItem",
|
||||
"CoordSessionLock",
|
||||
"CoordComponentState",
|
||||
"CoordMessage",
|
||||
"BackupLog",
|
||||
"Base",
|
||||
"BillableTime",
|
||||
|
||||
57
api/models/coord_component_state.py
Normal file
57
api/models/coord_component_state.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Coordination component state model."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import PrimaryKeyConstraint, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class CoordComponentState(Base, TimestampMixin):
|
||||
"""Current state of a named component within a project."""
|
||||
|
||||
__tablename__ = "coord_component_states"
|
||||
|
||||
project_key: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
primary_key=True,
|
||||
doc="Project namespace"
|
||||
)
|
||||
|
||||
component: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
primary_key=True,
|
||||
doc="Component name, e.g. 'server', 'agent', 'dashboard', 'database'"
|
||||
)
|
||||
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
doc="State: deployed, building, stable, broken, unknown"
|
||||
)
|
||||
|
||||
version: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
doc="Version string or git SHA"
|
||||
)
|
||||
|
||||
notes: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
doc="Freeform notes about current state"
|
||||
)
|
||||
|
||||
updated_by: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Session that last updated this record"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("project_key", "component", name="pk_coord_component_states"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CoordComponentState(project_key='{self.project_key}', component='{self.component}', state='{self.state}')>"
|
||||
56
api/models/coord_message.py
Normal file
56
api/models/coord_message.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Coordination inter-session message model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class CoordMessage(Base, UUIDMixin, TimestampMixin):
|
||||
"""A message sent from one session to another (or broadcast)."""
|
||||
|
||||
__tablename__ = "coord_messages"
|
||||
|
||||
from_session: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Sending session, e.g. 'DESKTOP-0O8A1RL/Claude'"
|
||||
)
|
||||
|
||||
to_session: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
doc="Recipient session; NULL means broadcast to all"
|
||||
)
|
||||
|
||||
project_key: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
doc="Optional project context for the message"
|
||||
)
|
||||
|
||||
subject: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
nullable=False,
|
||||
doc="Message subject line"
|
||||
)
|
||||
|
||||
body: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
doc="Message body, markdown ok"
|
||||
)
|
||||
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="NULL means unread"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_coord_messages_to_read", "to_session", "read_at"),
|
||||
Index("idx_coord_messages_from", "from_session"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CoordMessage(from='{self.from_session}', to='{self.to_session}', subject='{self.subject}')>"
|
||||
64
api/models/coord_session_lock.py
Normal file
64
api/models/coord_session_lock.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Coordination session lock model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class CoordSessionLock(Base, UUIDMixin, TimestampMixin):
|
||||
"""An exclusive lock held by a session on a resource path."""
|
||||
|
||||
__tablename__ = "coord_session_locks"
|
||||
|
||||
project_key: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Project namespace this lock applies to"
|
||||
)
|
||||
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Session holding the lock, e.g. 'DESKTOP-0O8A1RL/Claude'"
|
||||
)
|
||||
|
||||
resource: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
nullable=False,
|
||||
doc="Resource path being locked, e.g. 'server/src/', 'migrations/'"
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
doc="Why this lock was acquired"
|
||||
)
|
||||
|
||||
acquired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
doc="When the lock was claimed"
|
||||
)
|
||||
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="NULL means no expiry; otherwise the lock expires at this time"
|
||||
)
|
||||
|
||||
released_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="NULL means still held; set when lock is explicitly released"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_coord_locks_project_resource", "project_key", "resource"),
|
||||
Index("idx_coord_locks_session", "session_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CoordSessionLock(project_key='{self.project_key}', resource='{self.resource}', session_id='{self.session_id}')>"
|
||||
87
api/models/coord_work_item.py
Normal file
87
api/models/coord_work_item.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Coordination work item model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import CHAR, CheckConstraint, ForeignKey, Index, Integer, String, Text, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class CoordWorkItem(Base, UUIDMixin, TimestampMixin):
|
||||
"""A discrete task within a coordination workflow."""
|
||||
|
||||
__tablename__ = "coord_work_items"
|
||||
|
||||
workflow_id: Mapped[str] = mapped_column(
|
||||
CHAR(36),
|
||||
ForeignKey("coord_workflows.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
doc="Parent workflow"
|
||||
)
|
||||
|
||||
project_key: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Denormalized project key for filtering without join"
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
nullable=False,
|
||||
doc="Short title"
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
doc="Full description, markdown ok — store design specs, schemas, etc."
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
doc="Status: pending, in_progress, blocked, completed, cancelled"
|
||||
)
|
||||
|
||||
priority: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
doc="Higher value = more urgent"
|
||||
)
|
||||
|
||||
assigned_session: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
doc="Session currently working this item"
|
||||
)
|
||||
|
||||
depends_on_id: Mapped[Optional[str]] = mapped_column(
|
||||
CHAR(36),
|
||||
ForeignKey("coord_work_items.id", ondelete="SET NULL"),
|
||||
doc="Blocking predecessor item"
|
||||
)
|
||||
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="When work began"
|
||||
)
|
||||
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="When item reached a terminal state"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'in_progress', 'blocked', 'completed', 'cancelled')",
|
||||
name="ck_coord_work_items_status"
|
||||
),
|
||||
Index("idx_coord_work_items_workflow", "workflow_id"),
|
||||
Index("idx_coord_work_items_project_status", "project_key", "status"),
|
||||
Index("idx_coord_work_items_assigned", "assigned_session"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CoordWorkItem(title='{self.title}', status='{self.status}')>"
|
||||
61
api/models/coord_workflow.py
Normal file
61
api/models/coord_workflow.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Coordination workflow model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import CHAR, CheckConstraint, Index, String, Text, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class CoordWorkflow(Base, UUIDMixin, TimestampMixin):
|
||||
"""A named unit of work spanning one or more sessions."""
|
||||
|
||||
__tablename__ = "coord_workflows"
|
||||
|
||||
project_key: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Project namespace slug, e.g. 'gururmm', 'client/acme-corp'"
|
||||
)
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Short identifier, e.g. 'discovery-feature'"
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
doc="Freeform description, markdown ok"
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="planning",
|
||||
doc="Status: planning, active, blocked, completed, cancelled"
|
||||
)
|
||||
|
||||
created_by: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
doc="Session that created this workflow, e.g. 'DESKTOP-0O8A1RL/Claude'"
|
||||
)
|
||||
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
doc="When the workflow reached a terminal state"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('planning', 'active', 'blocked', 'completed', 'cancelled')",
|
||||
name="ck_coord_workflows_status"
|
||||
),
|
||||
Index("idx_coord_workflows_project_status", "project_key", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CoordWorkflow(project_key='{self.project_key}', name='{self.name}', status='{self.status}')>"
|
||||
38
api/routers/coord_components.py
Normal file
38
api/routers/coord_components.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Coordination component states router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_component_state import CoordComponentStateUpsert, CoordComponentStateResponse
|
||||
from api.services import coord_component_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def list_component_states(
|
||||
project_key: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all component states, optionally filtered by project."""
|
||||
states = coord_component_service.get_component_states(db, project_key=project_key)
|
||||
return {
|
||||
"total": len(states),
|
||||
"states": [CoordComponentStateResponse.model_validate(s) for s in states],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{project_key}/{component}", response_model=CoordComponentStateResponse, status_code=status.HTTP_200_OK)
|
||||
def upsert_component_state(
|
||||
project_key: str,
|
||||
component: str,
|
||||
data: CoordComponentStateUpsert,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create or update the state of a component within a project."""
|
||||
state = coord_component_service.upsert_component_state(db, project_key, component, data)
|
||||
return CoordComponentStateResponse.model_validate(state)
|
||||
81
api/routers/coord_locks.py
Normal file
81
api/routers/coord_locks.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Coordination session locks router."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_session_lock import CoordSessionLockCreate, CoordSessionLockResponse
|
||||
from api.services import coord_lock_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def list_active_locks(
|
||||
project_key: str | None = Query(default=None),
|
||||
session_id: str | None = Query(default=None),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List currently active locks with optional filters."""
|
||||
locks, total = coord_lock_service.get_active_locks(
|
||||
db, project_key=project_key, session_id=session_id, skip=skip, limit=limit
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"locks": [CoordSessionLockResponse.model_validate(l) for l in locks],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def check_resource_locked(
|
||||
project_key: str = Query(...),
|
||||
resource: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Check whether a resource is currently locked."""
|
||||
lock = coord_lock_service.check_resource_locked(db, project_key, resource)
|
||||
if lock:
|
||||
return {"locked": True, "lock": CoordSessionLockResponse.model_validate(lock)}
|
||||
return {"locked": False}
|
||||
|
||||
|
||||
@router.post("", response_model=CoordSessionLockResponse, status_code=status.HTTP_201_CREATED)
|
||||
def claim_lock(
|
||||
data: CoordSessionLockCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Claim a resource lock for a session."""
|
||||
lock = coord_lock_service.claim_lock(db, data)
|
||||
return CoordSessionLockResponse.model_validate(lock)
|
||||
|
||||
|
||||
@router.delete("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def release_all_session_locks(
|
||||
session_id: str = Query(..., description="Release all active locks held by this session"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Release all active locks for a session (call on session end)."""
|
||||
return coord_lock_service.release_all_session_locks(db, session_id)
|
||||
|
||||
|
||||
@router.delete("/{lock_id}", response_model=CoordSessionLockResponse, status_code=status.HTTP_200_OK)
|
||||
def release_lock(
|
||||
lock_id: UUID,
|
||||
session_id: str = Query(..., description="Must match the session that claimed the lock"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Release a specific lock by ID."""
|
||||
lock = coord_lock_service.release_lock(db, lock_id, session_id)
|
||||
return CoordSessionLockResponse.model_validate(lock)
|
||||
77
api/routers/coord_messages.py
Normal file
77
api/routers/coord_messages.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Coordination inter-session messages router."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_message import CoordMessageCreate, CoordMessageResponse
|
||||
from api.services import coord_message_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def list_messages(
|
||||
to_session: str | None = Query(default=None),
|
||||
unread_only: bool = Query(default=False),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List messages with optional filters."""
|
||||
messages, total = coord_message_service.get_messages(
|
||||
db, to_session=to_session, unread_only=unread_only, skip=skip, limit=limit
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"messages": [CoordMessageResponse.model_validate(m) for m in messages],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def get_unread_count(
|
||||
session_id: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Return the count of unread messages for a session."""
|
||||
count = coord_message_service.get_unread_count(db, session_id)
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.post("", response_model=CoordMessageResponse, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
data: CoordMessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Send a message to a session or broadcast."""
|
||||
msg = coord_message_service.send_message(db, data)
|
||||
return CoordMessageResponse.model_validate(msg)
|
||||
|
||||
|
||||
@router.put("/{message_id}/read", response_model=CoordMessageResponse, status_code=status.HTTP_200_OK)
|
||||
def mark_message_read(
|
||||
message_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a message as read."""
|
||||
msg = coord_message_service.mark_read(db, message_id)
|
||||
return CoordMessageResponse.model_validate(msg)
|
||||
|
||||
|
||||
@router.delete("/{message_id}", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def delete_message(
|
||||
message_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a message."""
|
||||
return coord_message_service.delete_message(db, message_id)
|
||||
46
api/routers/coord_status.py
Normal file
46
api/routers/coord_status.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Coordination status overview router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_session_lock import CoordSessionLockResponse
|
||||
from api.schemas.coord_workflow import CoordWorkflowResponse
|
||||
from api.schemas.coord_component_state import CoordComponentStateResponse
|
||||
from api.services import coord_lock_service, coord_workflow_service, coord_component_service, coord_message_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def get_coordination_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Return a cross-project snapshot: active locks, in-progress workflows, component states, unread message counts."""
|
||||
active_locks, lock_total = coord_lock_service.get_active_locks(db, limit=200)
|
||||
active_workflows, wf_total = coord_workflow_service.get_workflows(db, status_filter="active", limit=200)
|
||||
component_states = coord_component_service.get_component_states(db)
|
||||
|
||||
# Group active locks by project
|
||||
locks_by_project: dict = {}
|
||||
for lock in active_locks:
|
||||
locks_by_project.setdefault(lock.project_key, []).append(
|
||||
CoordSessionLockResponse.model_validate(lock)
|
||||
)
|
||||
|
||||
# Group component states by project
|
||||
components_by_project: dict = {}
|
||||
for state in component_states:
|
||||
components_by_project.setdefault(state.project_key, []).append(
|
||||
CoordComponentStateResponse.model_validate(state)
|
||||
)
|
||||
|
||||
return {
|
||||
"active_lock_count": lock_total,
|
||||
"active_workflow_count": wf_total,
|
||||
"locks_by_project": {k: v for k, v in locks_by_project.items()},
|
||||
"active_workflows": [CoordWorkflowResponse.model_validate(w) for w in active_workflows],
|
||||
"components_by_project": {k: v for k, v in components_by_project.items()},
|
||||
}
|
||||
86
api/routers/coord_work_items.py
Normal file
86
api/routers/coord_work_items.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Coordination work items router."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_work_item import CoordWorkItemCreate, CoordWorkItemResponse, CoordWorkItemUpdate
|
||||
from api.services import coord_work_item_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def list_work_items(
|
||||
workflow_id: str | None = Query(default=None),
|
||||
project_key: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None),
|
||||
assigned_session: str | None = Query(default=None),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List work items with optional filters."""
|
||||
items, total = coord_work_item_service.get_work_items(
|
||||
db,
|
||||
workflow_id=workflow_id,
|
||||
project_key=project_key,
|
||||
status_filter=status_filter,
|
||||
assigned_session=assigned_session,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"work_items": [CoordWorkItemResponse.model_validate(i) for i in items],
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=CoordWorkItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_work_item(
|
||||
data: CoordWorkItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new work item within a workflow."""
|
||||
item = coord_work_item_service.create_work_item(db, data)
|
||||
return CoordWorkItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=CoordWorkItemResponse, status_code=status.HTTP_200_OK)
|
||||
def get_work_item(
|
||||
item_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a work item by ID."""
|
||||
item = coord_work_item_service.get_work_item_by_id(db, item_id)
|
||||
return CoordWorkItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=CoordWorkItemResponse, status_code=status.HTTP_200_OK)
|
||||
def update_work_item(
|
||||
item_id: UUID,
|
||||
data: CoordWorkItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a work item."""
|
||||
item = coord_work_item_service.update_work_item(db, item_id, data)
|
||||
return CoordWorkItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def delete_work_item(
|
||||
item_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a work item."""
|
||||
return coord_work_item_service.delete_work_item(db, item_id)
|
||||
83
api/routers/coord_workflows.py
Normal file
83
api/routers/coord_workflows.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Coordination workflows router."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.coord_workflow import CoordWorkflowCreate, CoordWorkflowResponse, CoordWorkflowUpdate
|
||||
from api.schemas.coord_work_item import CoordWorkItemResponse
|
||||
from api.services import coord_workflow_service, coord_work_item_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def list_workflows(
|
||||
project_key: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List workflows with optional filters."""
|
||||
workflows, total = coord_workflow_service.get_workflows(
|
||||
db, project_key=project_key, status_filter=status_filter, skip=skip, limit=limit
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"workflows": [CoordWorkflowResponse.model_validate(w) for w in workflows],
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=CoordWorkflowResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_workflow(
|
||||
data: CoordWorkflowCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new coordination workflow."""
|
||||
workflow = coord_workflow_service.create_workflow(db, data)
|
||||
return CoordWorkflowResponse.model_validate(workflow)
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def get_workflow(
|
||||
workflow_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a workflow by ID including its work items."""
|
||||
workflow = coord_workflow_service.get_workflow_by_id(db, workflow_id)
|
||||
items, _ = coord_work_item_service.get_work_items(db, workflow_id=str(workflow_id))
|
||||
return {
|
||||
"workflow": CoordWorkflowResponse.model_validate(workflow),
|
||||
"work_items": [CoordWorkItemResponse.model_validate(i) for i in items],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", response_model=CoordWorkflowResponse, status_code=status.HTTP_200_OK)
|
||||
def update_workflow(
|
||||
workflow_id: UUID,
|
||||
data: CoordWorkflowUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a workflow."""
|
||||
workflow = coord_workflow_service.update_workflow(db, workflow_id, data)
|
||||
return CoordWorkflowResponse.model_validate(workflow)
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", response_model=dict, status_code=status.HTTP_200_OK)
|
||||
def delete_workflow(
|
||||
workflow_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a workflow and its work items (cascade)."""
|
||||
return coord_workflow_service.delete_workflow(db, workflow_id)
|
||||
30
api/schemas/coord_component_state.py
Normal file
30
api/schemas/coord_component_state.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Pydantic schemas for CoordComponentState."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoordComponentStateUpsert(BaseModel):
|
||||
"""Input schema for upserting a component state."""
|
||||
|
||||
state: str = Field(..., description="State: deployed, building, stable, broken, unknown", max_length=50)
|
||||
version: Optional[str] = Field(None, description="Version string or git SHA", max_length=100)
|
||||
notes: Optional[str] = Field(None, description="Freeform notes")
|
||||
updated_by: str = Field(..., description="Session performing this update", max_length=200)
|
||||
|
||||
|
||||
class CoordComponentStateResponse(BaseModel):
|
||||
"""Output schema for a component state."""
|
||||
|
||||
project_key: str
|
||||
component: str
|
||||
state: str
|
||||
version: Optional[str]
|
||||
notes: Optional[str]
|
||||
updated_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
39
api/schemas/coord_message.py
Normal file
39
api/schemas/coord_message.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Pydantic schemas for CoordMessage."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoordMessageCreate(BaseModel):
|
||||
"""Input schema for sending a message."""
|
||||
|
||||
from_session: str = Field(..., description="Sending session identifier", max_length=200)
|
||||
to_session: Optional[str] = Field(None, description="Recipient session; NULL = broadcast", max_length=200)
|
||||
project_key: Optional[str] = Field(None, description="Optional project context", max_length=200)
|
||||
subject: str = Field(..., description="Message subject", max_length=500)
|
||||
body: str = Field(..., description="Message body, markdown ok")
|
||||
|
||||
|
||||
class CoordMessageUpdate(BaseModel):
|
||||
"""Input schema for updating a message (mark read, etc.)."""
|
||||
|
||||
read_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CoordMessageResponse(BaseModel):
|
||||
"""Output schema for a message."""
|
||||
|
||||
id: UUID
|
||||
from_session: str
|
||||
to_session: Optional[str]
|
||||
project_key: Optional[str]
|
||||
subject: str
|
||||
body: str
|
||||
read_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
41
api/schemas/coord_session_lock.py
Normal file
41
api/schemas/coord_session_lock.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Pydantic schemas for CoordSessionLock."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoordSessionLockCreate(BaseModel):
|
||||
"""Input schema for claiming a lock."""
|
||||
|
||||
project_key: str = Field(..., description="Project namespace", max_length=200)
|
||||
session_id: str = Field(..., description="Session claiming the lock", max_length=200)
|
||||
resource: str = Field(..., description="Resource path being locked, e.g. 'server/src/'", max_length=500)
|
||||
description: Optional[str] = Field(None, description="Why this lock is needed")
|
||||
ttl_hours: float = Field(2.0, description="Lock lifetime in hours; 0 = no expiry", ge=0)
|
||||
|
||||
|
||||
class CoordSessionLockUpdate(BaseModel):
|
||||
"""Input schema for updating a lock (rarely needed directly)."""
|
||||
|
||||
description: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CoordSessionLockResponse(BaseModel):
|
||||
"""Output schema for a lock."""
|
||||
|
||||
id: UUID
|
||||
project_key: str
|
||||
session_id: str
|
||||
resource: str
|
||||
description: Optional[str]
|
||||
acquired_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
released_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
53
api/schemas/coord_work_item.py
Normal file
53
api/schemas/coord_work_item.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Pydantic schemas for CoordWorkItem."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoordWorkItemCreate(BaseModel):
|
||||
"""Input schema for creating a work item."""
|
||||
|
||||
workflow_id: str = Field(..., description="Parent workflow UUID", max_length=36)
|
||||
project_key: str = Field(..., description="Project namespace slug", max_length=200)
|
||||
title: str = Field(..., description="Short title", max_length=500)
|
||||
description: Optional[str] = Field(None, description="Full description, markdown ok")
|
||||
status: str = Field("pending", description="Status: pending, in_progress, blocked, completed, cancelled")
|
||||
priority: int = Field(0, description="Higher value = more urgent")
|
||||
assigned_session: Optional[str] = Field(None, max_length=200)
|
||||
depends_on_id: Optional[str] = Field(None, description="Blocking predecessor item UUID", max_length=36)
|
||||
|
||||
|
||||
class CoordWorkItemUpdate(BaseModel):
|
||||
"""Input schema for updating a work item. All fields optional."""
|
||||
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = Field(None, description="Status: pending, in_progress, blocked, completed, cancelled")
|
||||
priority: Optional[int] = None
|
||||
assigned_session: Optional[str] = Field(None, max_length=200)
|
||||
depends_on_id: Optional[str] = Field(None, max_length=36)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CoordWorkItemResponse(BaseModel):
|
||||
"""Output schema for a work item."""
|
||||
|
||||
id: UUID
|
||||
workflow_id: str
|
||||
project_key: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
priority: int
|
||||
assigned_session: Optional[str]
|
||||
depends_on_id: Optional[str]
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
42
api/schemas/coord_workflow.py
Normal file
42
api/schemas/coord_workflow.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Pydantic schemas for CoordWorkflow."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoordWorkflowCreate(BaseModel):
|
||||
"""Input schema for creating a workflow."""
|
||||
|
||||
project_key: str = Field(..., description="Project namespace slug", max_length=200)
|
||||
name: str = Field(..., description="Short workflow identifier", max_length=200)
|
||||
description: Optional[str] = Field(None, description="Freeform description, markdown ok")
|
||||
status: str = Field("planning", description="Status: planning, active, blocked, completed, cancelled")
|
||||
created_by: str = Field(..., description="Creating session identifier", max_length=200)
|
||||
|
||||
|
||||
class CoordWorkflowUpdate(BaseModel):
|
||||
"""Input schema for updating a workflow. All fields optional."""
|
||||
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = Field(None, description="Status: planning, active, blocked, completed, cancelled")
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CoordWorkflowResponse(BaseModel):
|
||||
"""Output schema for a workflow."""
|
||||
|
||||
id: UUID
|
||||
project_key: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
created_by: str
|
||||
completed_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
63
api/services/coord_component_service.py
Normal file
63
api/services/coord_component_service.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Service layer for CoordComponentState."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.coord_component_state import CoordComponentState
|
||||
from api.schemas.coord_component_state import CoordComponentStateUpsert
|
||||
|
||||
|
||||
def get_component_states(
|
||||
db: Session,
|
||||
project_key: Optional[str] = None,
|
||||
) -> list[CoordComponentState]:
|
||||
"""Return all component states, optionally filtered by project."""
|
||||
q = db.query(CoordComponentState)
|
||||
if project_key:
|
||||
q = q.filter(CoordComponentState.project_key == project_key)
|
||||
return q.order_by(CoordComponentState.project_key, CoordComponentState.component).all()
|
||||
|
||||
|
||||
def upsert_component_state(
|
||||
db: Session,
|
||||
project_key: str,
|
||||
component: str,
|
||||
data: CoordComponentStateUpsert,
|
||||
) -> CoordComponentState:
|
||||
"""Insert or update a component state using MariaDB ON DUPLICATE KEY UPDATE."""
|
||||
try:
|
||||
stmt = insert(CoordComponentState).values(
|
||||
project_key=project_key,
|
||||
component=component,
|
||||
state=data.state,
|
||||
version=data.version,
|
||||
notes=data.notes,
|
||||
updated_by=data.updated_by,
|
||||
)
|
||||
stmt = stmt.on_duplicate_key_update(
|
||||
state=stmt.inserted.state,
|
||||
version=stmt.inserted.version,
|
||||
notes=stmt.inserted.notes,
|
||||
updated_by=stmt.inserted.updated_by,
|
||||
)
|
||||
db.execute(stmt)
|
||||
db.commit()
|
||||
|
||||
record = (
|
||||
db.query(CoordComponentState)
|
||||
.filter(
|
||||
CoordComponentState.project_key == project_key,
|
||||
CoordComponentState.component == component,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return record
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to upsert component state: {e}"
|
||||
)
|
||||
128
api/services/coord_lock_service.py
Normal file
128
api/services/coord_lock_service.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Service layer for CoordSessionLock."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.coord_session_lock import CoordSessionLock
|
||||
from api.schemas.coord_session_lock import CoordSessionLockCreate
|
||||
|
||||
|
||||
def _active_filter(q):
|
||||
"""Apply the 'lock is currently active' predicate to a query."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
return q.filter(
|
||||
CoordSessionLock.released_at.is_(None),
|
||||
or_(
|
||||
CoordSessionLock.expires_at.is_(None),
|
||||
CoordSessionLock.expires_at > now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_active_locks(
|
||||
db: Session,
|
||||
project_key: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[CoordSessionLock], int]:
|
||||
"""Return currently active locks with optional filters."""
|
||||
q = db.query(CoordSessionLock)
|
||||
if project_key:
|
||||
q = q.filter(CoordSessionLock.project_key == project_key)
|
||||
if session_id:
|
||||
q = q.filter(CoordSessionLock.session_id == session_id)
|
||||
q = _active_filter(q)
|
||||
total = q.count()
|
||||
locks = q.order_by(CoordSessionLock.acquired_at.desc()).offset(skip).limit(limit).all()
|
||||
return locks, total
|
||||
|
||||
|
||||
def check_resource_locked(
|
||||
db: Session, project_key: str, resource: str
|
||||
) -> Optional[CoordSessionLock]:
|
||||
"""Return the active lock on a resource, or None if unlocked."""
|
||||
q = db.query(CoordSessionLock).filter(
|
||||
CoordSessionLock.project_key == project_key,
|
||||
CoordSessionLock.resource == resource,
|
||||
)
|
||||
return _active_filter(q).first()
|
||||
|
||||
|
||||
def claim_lock(db: Session, data: CoordSessionLockCreate) -> CoordSessionLock:
|
||||
"""Claim a resource lock, computing expires_at from ttl_hours."""
|
||||
expires_at: Optional[datetime] = None
|
||||
if data.ttl_hours > 0:
|
||||
expires_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=data.ttl_hours)
|
||||
|
||||
try:
|
||||
lock = CoordSessionLock(
|
||||
project_key=data.project_key,
|
||||
session_id=data.session_id,
|
||||
resource=data.resource,
|
||||
description=data.description,
|
||||
acquired_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
expires_at=expires_at,
|
||||
released_at=None,
|
||||
)
|
||||
db.add(lock)
|
||||
db.commit()
|
||||
db.refresh(lock)
|
||||
return lock
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to claim lock: {e}"
|
||||
)
|
||||
|
||||
|
||||
def release_lock(db: Session, lock_id: UUID, session_id: str) -> CoordSessionLock:
|
||||
"""Release a specific lock; only the owning session may release it."""
|
||||
lock = db.query(CoordSessionLock).filter(CoordSessionLock.id == str(lock_id)).first()
|
||||
if not lock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Lock {lock_id} not found"
|
||||
)
|
||||
if lock.session_id != session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the session that claimed this lock may release it"
|
||||
)
|
||||
try:
|
||||
lock.released_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.commit()
|
||||
db.refresh(lock)
|
||||
return lock
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to release lock: {e}"
|
||||
)
|
||||
|
||||
|
||||
def release_all_session_locks(db: Session, session_id: str) -> dict:
|
||||
"""Release all active locks held by a session (cleanup on session end)."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
try:
|
||||
q = db.query(CoordSessionLock).filter(
|
||||
CoordSessionLock.session_id == session_id,
|
||||
CoordSessionLock.released_at.is_(None),
|
||||
)
|
||||
count = q.count()
|
||||
q.update({"released_at": now}, synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": f"Released {count} lock(s) for session '{session_id}'", "count": count}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to release session locks: {e}"
|
||||
)
|
||||
96
api/services/coord_message_service.py
Normal file
96
api/services/coord_message_service.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Service layer for CoordMessage."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.coord_message import CoordMessage
|
||||
from api.schemas.coord_message import CoordMessageCreate
|
||||
|
||||
|
||||
def get_messages(
|
||||
db: Session,
|
||||
to_session: Optional[str] = None,
|
||||
unread_only: bool = False,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[CoordMessage], int]:
|
||||
"""Return paginated messages with optional filters."""
|
||||
q = db.query(CoordMessage)
|
||||
if to_session:
|
||||
q = q.filter(CoordMessage.to_session == to_session)
|
||||
if unread_only:
|
||||
q = q.filter(CoordMessage.read_at.is_(None))
|
||||
total = q.count()
|
||||
messages = q.order_by(CoordMessage.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return messages, total
|
||||
|
||||
|
||||
def get_unread_count(db: Session, session_id: str) -> int:
|
||||
"""Return the number of unread messages addressed to a session."""
|
||||
return (
|
||||
db.query(CoordMessage)
|
||||
.filter(CoordMessage.to_session == session_id, CoordMessage.read_at.is_(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def get_message_by_id(db: Session, message_id: UUID) -> CoordMessage:
|
||||
"""Return a single message or raise 404."""
|
||||
msg = db.query(CoordMessage).filter(CoordMessage.id == str(message_id)).first()
|
||||
if not msg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Message {message_id} not found"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def send_message(db: Session, data: CoordMessageCreate) -> CoordMessage:
|
||||
"""Persist a new message."""
|
||||
try:
|
||||
msg = CoordMessage(**data.model_dump())
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to send message: {e}"
|
||||
)
|
||||
|
||||
|
||||
def mark_read(db: Session, message_id: UUID) -> CoordMessage:
|
||||
"""Mark a message as read at the current time."""
|
||||
msg = get_message_by_id(db, message_id)
|
||||
try:
|
||||
msg.read_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to mark message read: {e}"
|
||||
)
|
||||
|
||||
|
||||
def delete_message(db: Session, message_id: UUID) -> dict:
|
||||
"""Delete a message by ID."""
|
||||
msg = get_message_by_id(db, message_id)
|
||||
try:
|
||||
db.delete(msg)
|
||||
db.commit()
|
||||
return {"message": "Message deleted", "message_id": str(message_id)}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete message: {e}"
|
||||
)
|
||||
100
api/services/coord_work_item_service.py
Normal file
100
api/services/coord_work_item_service.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Service layer for CoordWorkItem."""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.coord_work_item import CoordWorkItem
|
||||
from api.models.coord_workflow import CoordWorkflow
|
||||
from api.schemas.coord_work_item import CoordWorkItemCreate, CoordWorkItemUpdate
|
||||
|
||||
|
||||
def get_work_items(
|
||||
db: Session,
|
||||
workflow_id: Optional[str] = None,
|
||||
project_key: Optional[str] = None,
|
||||
status_filter: Optional[str] = None,
|
||||
assigned_session: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[CoordWorkItem], int]:
|
||||
"""Return paginated work items with optional filters."""
|
||||
q = db.query(CoordWorkItem)
|
||||
if workflow_id:
|
||||
q = q.filter(CoordWorkItem.workflow_id == workflow_id)
|
||||
if project_key:
|
||||
q = q.filter(CoordWorkItem.project_key == project_key)
|
||||
if status_filter:
|
||||
q = q.filter(CoordWorkItem.status == status_filter)
|
||||
if assigned_session:
|
||||
q = q.filter(CoordWorkItem.assigned_session == assigned_session)
|
||||
total = q.count()
|
||||
items = q.order_by(CoordWorkItem.priority.desc(), CoordWorkItem.created_at.asc()).offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
|
||||
def get_work_item_by_id(db: Session, item_id: UUID) -> CoordWorkItem:
|
||||
"""Return a single work item or raise 404."""
|
||||
item = db.query(CoordWorkItem).filter(CoordWorkItem.id == str(item_id)).first()
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Work item {item_id} not found"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def create_work_item(db: Session, data: CoordWorkItemCreate) -> CoordWorkItem:
|
||||
"""Create and persist a new work item, validating the parent workflow exists."""
|
||||
workflow = db.query(CoordWorkflow).filter(CoordWorkflow.id == data.workflow_id).first()
|
||||
if not workflow:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Workflow {data.workflow_id} not found"
|
||||
)
|
||||
try:
|
||||
item = CoordWorkItem(**data.model_dump())
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create work item: {e}"
|
||||
)
|
||||
|
||||
|
||||
def update_work_item(db: Session, item_id: UUID, data: CoordWorkItemUpdate) -> CoordWorkItem:
|
||||
"""Apply partial update to a work item."""
|
||||
item = get_work_item_by_id(db, item_id)
|
||||
try:
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(item, field, value)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update work item: {e}"
|
||||
)
|
||||
|
||||
|
||||
def delete_work_item(db: Session, item_id: UUID) -> dict:
|
||||
"""Delete a work item by ID."""
|
||||
item = get_work_item_by_id(db, item_id)
|
||||
try:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"message": "Work item deleted", "item_id": str(item_id)}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete work item: {e}"
|
||||
)
|
||||
87
api/services/coord_workflow_service.py
Normal file
87
api/services/coord_workflow_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Service layer for CoordWorkflow."""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.coord_workflow import CoordWorkflow
|
||||
from api.schemas.coord_workflow import CoordWorkflowCreate, CoordWorkflowUpdate
|
||||
|
||||
|
||||
def get_workflows(
|
||||
db: Session,
|
||||
project_key: Optional[str] = None,
|
||||
status_filter: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[CoordWorkflow], int]:
|
||||
"""Return paginated workflows with optional filters."""
|
||||
q = db.query(CoordWorkflow)
|
||||
if project_key:
|
||||
q = q.filter(CoordWorkflow.project_key == project_key)
|
||||
if status_filter:
|
||||
q = q.filter(CoordWorkflow.status == status_filter)
|
||||
total = q.count()
|
||||
workflows = q.order_by(CoordWorkflow.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return workflows, total
|
||||
|
||||
|
||||
def get_workflow_by_id(db: Session, workflow_id: UUID) -> CoordWorkflow:
|
||||
"""Return a single workflow or raise 404."""
|
||||
workflow = db.query(CoordWorkflow).filter(CoordWorkflow.id == str(workflow_id)).first()
|
||||
if not workflow:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Workflow {workflow_id} not found"
|
||||
)
|
||||
return workflow
|
||||
|
||||
|
||||
def create_workflow(db: Session, data: CoordWorkflowCreate) -> CoordWorkflow:
|
||||
"""Create and persist a new workflow."""
|
||||
try:
|
||||
workflow = CoordWorkflow(**data.model_dump())
|
||||
db.add(workflow)
|
||||
db.commit()
|
||||
db.refresh(workflow)
|
||||
return workflow
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create workflow: {e}"
|
||||
)
|
||||
|
||||
|
||||
def update_workflow(db: Session, workflow_id: UUID, data: CoordWorkflowUpdate) -> CoordWorkflow:
|
||||
"""Apply partial update to a workflow."""
|
||||
workflow = get_workflow_by_id(db, workflow_id)
|
||||
try:
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(workflow, field, value)
|
||||
db.commit()
|
||||
db.refresh(workflow)
|
||||
return workflow
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update workflow: {e}"
|
||||
)
|
||||
|
||||
|
||||
def delete_workflow(db: Session, workflow_id: UUID) -> dict:
|
||||
"""Delete a workflow by ID."""
|
||||
workflow = get_workflow_by_id(db, workflow_id)
|
||||
try:
|
||||
db.delete(workflow)
|
||||
db.commit()
|
||||
return {"message": "Workflow deleted", "workflow_id": str(workflow_id)}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete workflow: {e}"
|
||||
)
|
||||
122
migrations/versions/20260512_120000_coord_agent_coordination.py
Normal file
122
migrations/versions/20260512_120000_coord_agent_coordination.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""coord_agent_coordination
|
||||
|
||||
Revision ID: 20260512_120000
|
||||
Revises: 20260309_074038
|
||||
Create Date: 2026-05-12 12:00:00
|
||||
|
||||
Creates the agent coordination tables:
|
||||
- coord_workflows: Named units of work spanning one or more sessions
|
||||
- coord_work_items: Discrete tasks within a workflow
|
||||
- coord_session_locks: Exclusive resource locks claimed by sessions
|
||||
- coord_component_states: Current state of named components per project (composite PK)
|
||||
- coord_messages: Inter-session messages and broadcasts
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260512_120000"
|
||||
down_revision: Union[str, None] = "20260309_074038"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create all coordination tables."""
|
||||
|
||||
op.create_table(
|
||||
"coord_workflows",
|
||||
sa.Column("id", sa.CHAR(36), primary_key=True),
|
||||
sa.Column("project_key", sa.String(200), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="planning"),
|
||||
sa.Column("created_by", sa.String(200), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('planning', 'active', 'blocked', 'completed', 'cancelled')",
|
||||
name="ck_coord_workflows_status",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_coord_workflows_project_status", "coord_workflows", ["project_key", "status"])
|
||||
|
||||
op.create_table(
|
||||
"coord_work_items",
|
||||
sa.Column("id", sa.CHAR(36), primary_key=True),
|
||||
sa.Column("workflow_id", sa.CHAR(36), sa.ForeignKey("coord_workflows.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_key", sa.String(200), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("assigned_session", sa.String(200), nullable=True),
|
||||
sa.Column("depends_on_id", sa.CHAR(36), sa.ForeignKey("coord_work_items.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'in_progress', 'blocked', 'completed', 'cancelled')",
|
||||
name="ck_coord_work_items_status",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_coord_work_items_workflow", "coord_work_items", ["workflow_id"])
|
||||
op.create_index("idx_coord_work_items_project_status", "coord_work_items", ["project_key", "status"])
|
||||
op.create_index("idx_coord_work_items_assigned", "coord_work_items", ["assigned_session"])
|
||||
|
||||
op.create_table(
|
||||
"coord_session_locks",
|
||||
sa.Column("id", sa.CHAR(36), primary_key=True),
|
||||
sa.Column("project_key", sa.String(200), nullable=False),
|
||||
sa.Column("session_id", sa.String(200), nullable=False),
|
||||
sa.Column("resource", sa.String(500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("acquired_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("released_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
)
|
||||
op.create_index("idx_coord_locks_project_resource", "coord_session_locks", ["project_key", "resource"])
|
||||
op.create_index("idx_coord_locks_session", "coord_session_locks", ["session_id"])
|
||||
|
||||
op.create_table(
|
||||
"coord_component_states",
|
||||
sa.Column("project_key", sa.String(200), nullable=False),
|
||||
sa.Column("component", sa.String(200), nullable=False),
|
||||
sa.Column("state", sa.String(50), nullable=False),
|
||||
sa.Column("version", sa.String(100), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("updated_by", sa.String(200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
sa.PrimaryKeyConstraint("project_key", "component", name="pk_coord_component_states"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"coord_messages",
|
||||
sa.Column("id", sa.CHAR(36), primary_key=True),
|
||||
sa.Column("from_session", sa.String(200), nullable=False),
|
||||
sa.Column("to_session", sa.String(200), nullable=True),
|
||||
sa.Column("project_key", sa.String(200), nullable=True),
|
||||
sa.Column("subject", sa.String(500), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("read_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
)
|
||||
op.create_index("idx_coord_messages_to_read", "coord_messages", ["to_session", "read_at"])
|
||||
op.create_index("idx_coord_messages_from", "coord_messages", ["from_session"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop all coordination tables in reverse dependency order."""
|
||||
op.drop_table("coord_messages")
|
||||
op.drop_table("coord_component_states")
|
||||
op.drop_table("coord_session_locks")
|
||||
op.drop_table("coord_work_items")
|
||||
op.drop_table("coord_workflows")
|
||||
Reference in New Issue
Block a user