generated from coulomb/repo-seed
feat(CUST-WP-0014): repo sync automation & Gitea inventory
- Migration e2f3a4b5c6d7: add last_state_synced_at to managed_repos
- consistency_check.py: PATCH last_state_synced_at after fix run;
fix ~ treated as non-empty state_hub_task_id (C-03 vs C-11);
fix _inject_task_id_into_block skipping injection when field exists
with null value
- install_hooks.sh: idempotent post-commit hook installer for all
registered repos (make install-hooks REPO= / install-hooks-all)
- gitea_inventory.py: compare coulomb Gitea org against state-hub
registered repos — registered / unregistered / hub-only sections
- infra/README.md: document systemd user timer + crontab fallback
- systemd user timer: custodian-sync.{service,timer} runs
fix-consistency-all every 15 min (enabled)
- dashboard/src/repo-sync.md: Repo Sync Health page — sync age table,
unregistered Gitea repos, hub-only repos
- api/routers/repos.py: GET /repos/{slug}/dispatch endpoint returning
active goal, pending tasks per workstream, human interventions
- mcp_server/server.py: get_repo_dispatch() MCP tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,9 @@ class ManagedRepo(Base, TimestampMixin):
|
||||
last_sbom_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_state_synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="repos", lazy="selectin"
|
||||
|
||||
@@ -7,7 +7,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.schemas.managed_repo import RepoCreate, RepoRead, RepoUpdate
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
RepoRead,
|
||||
RepoUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||
|
||||
@@ -91,6 +101,86 @@ async def archive_repo(
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
|
||||
async def get_repo_dispatch(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoDispatch:
|
||||
"""Return active workstreams, pending tasks, and goal for a repo.
|
||||
|
||||
This endpoint is the foundation for autonomous agent sessions: an agent can
|
||||
call it at session start to discover what work is pending without needing to
|
||||
read state-hub summary or scan workplan files manually.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
|
||||
# Active goal
|
||||
goal_result = await session.execute(
|
||||
select(RepoGoal)
|
||||
.where(RepoGoal.repo_id == repo.id, RepoGoal.status == "active")
|
||||
.order_by(RepoGoal.priority)
|
||||
.limit(1)
|
||||
)
|
||||
goal_obj = goal_result.scalar_one_or_none()
|
||||
active_goal = None
|
||||
if goal_obj:
|
||||
active_goal = {
|
||||
"id": str(goal_obj.id),
|
||||
"title": goal_obj.title,
|
||||
"description": goal_obj.description,
|
||||
"priority": goal_obj.priority,
|
||||
}
|
||||
|
||||
# Active workstreams
|
||||
ws_result = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
||||
.order_by(Workstream.created_at)
|
||||
)
|
||||
workstreams = list(ws_result.scalars().all())
|
||||
|
||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
||||
all_interventions: list[DispatchTask] = []
|
||||
|
||||
for ws in workstreams:
|
||||
task_result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
tasks = list(task_result.scalars().all())
|
||||
|
||||
pending = [
|
||||
DispatchTask(
|
||||
id=t.id,
|
||||
title=t.title,
|
||||
priority=t.priority,
|
||||
status=t.status,
|
||||
needs_human=t.needs_human,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
interventions = [t for t in pending if t.needs_human]
|
||||
all_interventions.extend(interventions)
|
||||
|
||||
dispatch_workstreams.append(
|
||||
DispatchWorkstream(
|
||||
id=ws.id,
|
||||
title=ws.title,
|
||||
status=ws.status,
|
||||
pending_tasks=pending,
|
||||
)
|
||||
)
|
||||
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
last_state_synced_at=repo.last_state_synced_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
@@ -20,6 +21,7 @@ class RepoUpdate(BaseModel):
|
||||
remote_url: str | None = None
|
||||
description: str | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
last_state_synced_at: datetime | None = None
|
||||
|
||||
|
||||
class RepoRead(BaseModel):
|
||||
@@ -36,5 +38,29 @@ class RepoRead(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
sbom_source: str | None = None
|
||||
last_sbom_at: datetime | None = None
|
||||
last_state_synced_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DispatchTask(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
priority: str
|
||||
status: str
|
||||
needs_human: bool
|
||||
|
||||
|
||||
class DispatchWorkstream(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
human_interventions: list[DispatchTask]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
Reference in New Issue
Block a user