generated from coulomb/repo-seed
feat(tasks): add needs_human intervention flag (CUST-WP-0009)
- Migration b4c5d6e7f8a9: adds needs_human (bool) + intervention_note (text) to tasks - API: needs_human filter on GET /tasks/; 422 if flagged without note - 3 MCP tools: flag_for_human, clear_human_flag, list_human_interventions - Dashboard: interventions.md with amber cards and "Mark done" button - Policy router + workstream DoD policy (workstream-dod.md) - Workstream lifecycle docs page + workplan CUST-WP-0010 - CLAUDE.md: add step 4 (run fix-consistency after workplan writes) - consistency_check.py: promote C-11 unlinked tasks from INFO to WARN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom
|
||||
from api.routers import domains, repos, contributions, sbom, policy
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,7 +24,7 @@ app = FastAPI(
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ app.include_router(progress.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -44,6 +44,8 @@ class Task(Base, TimestampMixin):
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
41
api/routers/policy.py
Normal file
41
api/routers/policy.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
|
||||
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
router = APIRouter(prefix="/policy", tags=["policy"])
|
||||
|
||||
|
||||
class PolicyRead(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
class PolicyUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _policy_path(name: str) -> Path:
|
||||
if not _VALID_NAME.match(name):
|
||||
raise HTTPException(status_code=400, detail="Invalid policy name")
|
||||
path = POLICY_DIR / f"{name}.md"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=PolicyRead)
|
||||
def get_policy(name: str) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
return PolicyRead(name=name, content=path.read_text())
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=PolicyRead)
|
||||
def update_policy(name: str, body: PolicyUpdate) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
path.write_text(body.content)
|
||||
return PolicyRead(name=name, content=body.content)
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,6 +16,7 @@ async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
assignee: str | None = None,
|
||||
needs_human: bool | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
@@ -25,6 +26,8 @@ async def list_tasks(
|
||||
q = q.where(Task.status == status)
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
if needs_human is not None:
|
||||
q = q.where(Task.needs_human == needs_human)
|
||||
q = q.order_by(Task.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
@@ -15,8 +16,16 @@ class TaskCreate(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool = False
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
@@ -26,14 +35,22 @@ class TaskUpdate(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool | None = None
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
|
||||
def blocking_reason_required_when_blocked(self) -> Self:
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -46,6 +63,8 @@ class TaskRead(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user