Add reconciliation state-change API contract

This commit is contained in:
2026-05-23 17:22:12 +02:00
parent 4d62768a3a
commit add650d4fa
5 changed files with 219 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ from api.routers import token_events
from api.routers import interface_changes
from api.routers import flows
from api.routers import recently_on_scope
from api.routers import reconciliation
class ETagMiddleware(BaseHTTPMiddleware):
@@ -100,6 +101,7 @@ app.include_router(tpsc.router)
app.include_router(token_events.router)
app.include_router(interface_changes.router)
app.include_router(flows.router)
app.include_router(reconciliation.router)
app.include_router(state.router)
app.include_router(policy.router)

View File

@@ -0,0 +1,99 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task
from api.models.workstream import Workstream
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import status_value
from api.services.reconciliation import (
classify_task_status_change,
classify_workstream_status_change,
)
from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
def _bool_or_default(value: bool | None, default: bool) -> bool:
return default if value is None else value
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
statuses = [status_value(row[0]) for row in result.all()]
return bool(statuses) and all(status in {"done", "cancelled"} for status in statuses)
@router.post("/state-change", response_model=StateChangeResponse)
async def classify_state_change(
body: StateChangeRequest,
session: AsyncSession = Depends(get_session),
) -> StateChangeResponse:
if body.target_type == "workstream":
ws = await session.get(Workstream, body.target_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
file_backed = _bool_or_default(body.file_backed, ws.repo_id is not None)
archived_file = _bool_or_default(body.archived_file, False)
tasks_terminal = (
body.tasks_terminal
if body.tasks_terminal is not None
else await _workstream_tasks_terminal(session, ws.id)
)
classification = classify_workstream_status_change(
current_status=ws.status,
target_status=body.target_status,
file_backed=file_backed,
archived_file=archived_file,
tasks_terminal=tasks_terminal,
)
return StateChangeResponse(
target_type=body.target_type,
target_id=body.target_id,
actor=body.actor,
intent=body.intent,
current_status=normalize_workstream_status(ws.status),
target_status=normalize_workstream_status(body.target_status),
file_backed=file_backed,
archived_file=archived_file,
tasks_terminal=tasks_terminal,
reconciliation_class=classification.reconciliation_class,
reason=classification.reason,
follow_up=classification.follow_up,
)
task = await session.get(Task, body.target_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
ws = await session.get(Workstream, task.workstream_id)
file_backed = _bool_or_default(body.file_backed, bool(ws and ws.repo_id))
archived_file = _bool_or_default(body.archived_file, False)
task_linked = _bool_or_default(body.task_linked, True)
classification = classify_task_status_change(
current_status=task.status,
target_status=body.target_status,
file_backed=file_backed,
archived_file=archived_file,
task_linked=task_linked,
blocking_reason=body.blocking_reason,
)
return StateChangeResponse(
target_type=body.target_type,
target_id=body.target_id,
actor=body.actor,
intent=body.intent,
current_status=status_value(task.status),
target_status=status_value(body.target_status),
file_backed=file_backed,
archived_file=archived_file,
task_linked=task_linked,
reconciliation_class=classification.reconciliation_class,
reason=classification.reason,
follow_up=classification.follow_up,
)

View File

@@ -0,0 +1,39 @@
import uuid
from typing import Literal
from pydantic import BaseModel
from api.services.reconciliation import ReconciliationClass
TargetType = Literal["workstream", "task"]
class StateChangeRequest(BaseModel):
target_type: TargetType
target_id: uuid.UUID
target_status: str
actor: str = "dashboard"
intent: str | None = None
file_backed: bool | None = None
archived_file: bool | None = None
task_linked: bool | None = None
tasks_terminal: bool | None = None
blocking_reason: str | None = None
class StateChangeResponse(BaseModel):
target_type: TargetType
target_id: uuid.UUID
actor: str
intent: str | None = None
current_status: str
target_status: str
file_backed: bool
archived_file: bool
task_linked: bool | None = None
tasks_terminal: bool | None = None
reconciliation_class: ReconciliationClass
reason: str
follow_up: str
write_through_result: Literal["not_attempted"] = "not_attempted"