2026-02-25 23:33:14 +01:00
|
|
|
import uuid
|
|
|
|
|
|
2026-06-04 08:25:31 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
2026-02-25 23:33:14 +01:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from api.database import get_session
|
2026-05-04 11:45:24 +02:00
|
|
|
from api.models.task import Task
|
2026-06-22 13:52:13 +02:00
|
|
|
from api.models.workplan import Workplan
|
|
|
|
|
from api.models.workplan_dependency import WorkplanDependency
|
|
|
|
|
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
|
2026-07-10 13:31:57 +02:00
|
|
|
from api.routers.workstreams import _legacy_key
|
|
|
|
|
from api.services.legacy_compat import retire_legacy_route
|
2026-02-25 23:33:14 +01:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
2026-06-04 08:25:31 +02:00
|
|
|
workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
|
2026-02-25 23:33:14 +01:00
|
|
|
|
|
|
|
|
|
2026-06-04 08:25:31 +02:00
|
|
|
async def _create_dependency(
|
|
|
|
|
*,
|
2026-06-22 13:52:13 +02:00
|
|
|
workplan_id: uuid.UUID,
|
|
|
|
|
body: WorkplanDependencyCreate,
|
2026-06-04 08:25:31 +02:00
|
|
|
session: AsyncSession,
|
2026-06-22 13:52:13 +02:00
|
|
|
) -> WorkplanDependency:
|
|
|
|
|
if await session.get(Workplan, workplan_id) is None:
|
2026-06-04 08:25:31 +02:00
|
|
|
raise HTTPException(status_code=404, detail="from workplan not found")
|
2026-05-04 11:45:24 +02:00
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
has_workplan_target = body.to_workplan_id is not None
|
2026-05-04 11:45:24 +02:00
|
|
|
has_task_target = body.to_task_id is not None
|
2026-06-22 13:52:13 +02:00
|
|
|
if has_workplan_target == has_task_target:
|
2026-05-04 11:45:24 +02:00
|
|
|
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
|
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None:
|
2026-06-04 08:25:31 +02:00
|
|
|
raise HTTPException(status_code=404, detail="target workplan not found")
|
2026-05-04 11:45:24 +02:00
|
|
|
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="target task not found")
|
2026-06-22 13:52:13 +02:00
|
|
|
if workplan_id == body.to_workplan_id:
|
2026-06-04 08:25:31 +02:00
|
|
|
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
CUST-WP-0074-T02: task wait qualifiers in the hub read model
Adds tasks.decision_id and workplan_dependencies.from_task_id (migration
e8f9a0b1c2d3). A from_task_id column is needed rather than
from_workplan_id plus description: the partial unique indexes key on
(from_workplan_id, target, relationship_type), so two tasks in one
workplan waiting on the same target could not both be indexed, and the
derived wait_kind needs per-task attribution. The two existing unique
indexes are narrowed to frontmatter edges (from_task_id IS NULL) and two
task-origin counterparts are added; downgrade deletes task-origin rows
before restoring the old indexes.
POST /workplans/{id}/dependencies/ accepts from_task_id (must belong to
the from workplan; a task cannot depend on itself). TaskCreate/Update/Read
carry decision_id.
Derived read-model fields, no new write routes (rule 5):
- TaskRead.wait_kind: external | human | both | unqualified, null unless
status is wait (dependency rows from this task / needs_human).
- WorkplanRead.blocked_kind: human | external | none, null unless status
is blocked. Human wins over external.
Both come from api/services/wait_kind.py, applied on GET /tasks/,
GET /tasks/{id}, GET /workplans/ and GET /workplans/{id}.
The identifier-migration reference-count proof now includes from_task_id
(FK count guard 22 -> 23).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
2026-09-28 22:39:18 +02:00
|
|
|
if body.from_task_id is not None:
|
|
|
|
|
from_task = await session.get(Task, body.from_task_id)
|
|
|
|
|
if from_task is None or from_task.workplan_id != workplan_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="from task not found in this workplan")
|
|
|
|
|
if body.from_task_id == body.to_task_id:
|
|
|
|
|
raise HTTPException(status_code=422, detail="a task cannot depend on itself")
|
2026-02-25 23:33:14 +01:00
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
dep = WorkplanDependency(
|
|
|
|
|
from_workplan_id=workplan_id,
|
CUST-WP-0074-T02: task wait qualifiers in the hub read model
Adds tasks.decision_id and workplan_dependencies.from_task_id (migration
e8f9a0b1c2d3). A from_task_id column is needed rather than
from_workplan_id plus description: the partial unique indexes key on
(from_workplan_id, target, relationship_type), so two tasks in one
workplan waiting on the same target could not both be indexed, and the
derived wait_kind needs per-task attribution. The two existing unique
indexes are narrowed to frontmatter edges (from_task_id IS NULL) and two
task-origin counterparts are added; downgrade deletes task-origin rows
before restoring the old indexes.
POST /workplans/{id}/dependencies/ accepts from_task_id (must belong to
the from workplan; a task cannot depend on itself). TaskCreate/Update/Read
carry decision_id.
Derived read-model fields, no new write routes (rule 5):
- TaskRead.wait_kind: external | human | both | unqualified, null unless
status is wait (dependency rows from this task / needs_human).
- WorkplanRead.blocked_kind: human | external | none, null unless status
is blocked. Human wins over external.
Both come from api/services/wait_kind.py, applied on GET /tasks/,
GET /tasks/{id}, GET /workplans/ and GET /workplans/{id}.
The identifier-migration reference-count proof now includes from_task_id
(FK count guard 22 -> 23).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
2026-09-28 22:39:18 +02:00
|
|
|
from_task_id=body.from_task_id,
|
2026-06-22 13:52:13 +02:00
|
|
|
to_workplan_id=body.to_workplan_id,
|
2026-05-04 11:45:24 +02:00
|
|
|
to_task_id=body.to_task_id,
|
|
|
|
|
relationship_type=body.relationship_type,
|
2026-02-25 23:33:14 +01:00
|
|
|
description=body.description,
|
|
|
|
|
)
|
|
|
|
|
session.add(dep)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(dep)
|
|
|
|
|
return dep
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 08:25:31 +02:00
|
|
|
async def _list_dependencies(
|
|
|
|
|
*,
|
2026-06-22 13:52:13 +02:00
|
|
|
workplan_id: uuid.UUID,
|
2026-06-04 08:25:31 +02:00
|
|
|
session: AsyncSession,
|
2026-06-22 13:52:13 +02:00
|
|
|
) -> list[WorkplanDependency]:
|
|
|
|
|
if await session.get(Workplan, workplan_id) is None:
|
2026-06-04 08:25:31 +02:00
|
|
|
raise HTTPException(status_code=404, detail="workplan not found")
|
2026-02-25 23:33:14 +01:00
|
|
|
rows = await session.execute(
|
2026-06-22 13:52:13 +02:00
|
|
|
select(WorkplanDependency).where(
|
|
|
|
|
(WorkplanDependency.from_workplan_id == workplan_id)
|
|
|
|
|
| (WorkplanDependency.to_workplan_id == workplan_id)
|
2026-02-25 23:33:14 +01:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return list(rows.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 08:25:31 +02:00
|
|
|
async def _delete_dependency(
|
|
|
|
|
*,
|
2026-06-22 13:52:13 +02:00
|
|
|
workplan_id: uuid.UUID,
|
2026-06-04 08:25:31 +02:00
|
|
|
dep_id: uuid.UUID,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
) -> None:
|
2026-06-22 13:52:13 +02:00
|
|
|
dep = await session.get(WorkplanDependency, dep_id)
|
2026-06-04 08:25:31 +02:00
|
|
|
if dep is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="dependency not found")
|
2026-06-22 13:52:13 +02:00
|
|
|
if dep.from_workplan_id != workplan_id:
|
2026-06-04 08:25:31 +02:00
|
|
|
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
|
|
|
|
await session.delete(dep)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/{workstream_id}/dependencies/",
|
2026-07-10 13:31:57 +02:00
|
|
|
status_code=status.HTTP_410_GONE,
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
async def create_dependency(
|
|
|
|
|
request: Request,
|
|
|
|
|
response: Response,
|
|
|
|
|
workstream_id: uuid.UUID,
|
2026-06-22 13:52:13 +02:00
|
|
|
body: WorkplanDependencyCreate,
|
2026-06-04 08:25:31 +02:00
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-07-10 13:31:57 +02:00
|
|
|
) -> None:
|
|
|
|
|
"""Legacy alias retired — use POST /workplans/{workplan_id}/dependencies/."""
|
|
|
|
|
await retire_legacy_route(
|
2026-06-04 08:25:31 +02:00
|
|
|
session=session,
|
|
|
|
|
request=request,
|
|
|
|
|
response=response,
|
|
|
|
|
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
|
|
|
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
2026-07-10 13:31:57 +02:00
|
|
|
detail="Legacy POST /workstreams/{workstream_id}/dependencies/ retired; "
|
|
|
|
|
"use POST /workplans/{workplan_id}/dependencies/",
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@workplan_router.post(
|
|
|
|
|
"/{workplan_id}/dependencies/",
|
2026-06-22 13:52:13 +02:00
|
|
|
response_model=WorkplanDependencyRead,
|
2026-06-04 08:25:31 +02:00
|
|
|
status_code=status.HTTP_201_CREATED,
|
|
|
|
|
)
|
|
|
|
|
async def create_workplan_dependency(
|
|
|
|
|
workplan_id: uuid.UUID,
|
2026-06-22 13:52:13 +02:00
|
|
|
body: WorkplanDependencyCreate,
|
2026-06-04 08:25:31 +02:00
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-06-22 13:52:13 +02:00
|
|
|
) -> WorkplanDependency:
|
|
|
|
|
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
|
2026-06-04 08:25:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/{workstream_id}/dependencies/",
|
2026-07-10 13:31:57 +02:00
|
|
|
status_code=status.HTTP_410_GONE,
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
async def list_dependencies(
|
|
|
|
|
request: Request,
|
|
|
|
|
response: Response,
|
|
|
|
|
workstream_id: uuid.UUID,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-07-10 13:31:57 +02:00
|
|
|
) -> None:
|
|
|
|
|
"""Legacy alias retired — use GET /workplans/{workplan_id}/dependencies/."""
|
|
|
|
|
await retire_legacy_route(
|
2026-06-04 08:25:31 +02:00
|
|
|
session=session,
|
|
|
|
|
request=request,
|
|
|
|
|
response=response,
|
|
|
|
|
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
|
|
|
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
2026-07-10 13:31:57 +02:00
|
|
|
detail="Legacy GET /workstreams/{workstream_id}/dependencies/ retired; "
|
|
|
|
|
"use GET /workplans/{workplan_id}/dependencies/",
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@workplan_router.get(
|
|
|
|
|
"/{workplan_id}/dependencies/",
|
2026-06-22 13:52:13 +02:00
|
|
|
response_model=list[WorkplanDependencyRead],
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
async def list_workplan_dependencies(
|
|
|
|
|
workplan_id: uuid.UUID,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-06-22 13:52:13 +02:00
|
|
|
) -> list[WorkplanDependency]:
|
|
|
|
|
return await _list_dependencies(workplan_id=workplan_id, session=session)
|
2026-06-04 08:25:31 +02:00
|
|
|
|
|
|
|
|
|
2026-02-25 23:33:14 +01:00
|
|
|
@router.delete(
|
|
|
|
|
"/{workstream_id}/dependencies/{dep_id}",
|
2026-07-10 13:31:57 +02:00
|
|
|
status_code=status.HTTP_410_GONE,
|
2026-02-25 23:33:14 +01:00
|
|
|
)
|
|
|
|
|
async def delete_dependency(
|
2026-06-04 08:25:31 +02:00
|
|
|
request: Request,
|
|
|
|
|
response: Response,
|
2026-02-25 23:33:14 +01:00
|
|
|
workstream_id: uuid.UUID,
|
|
|
|
|
dep_id: uuid.UUID,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> None:
|
2026-07-10 13:31:57 +02:00
|
|
|
"""Legacy alias retired — use DELETE /workplans/{workplan_id}/dependencies/{dep_id}."""
|
|
|
|
|
await retire_legacy_route(
|
2026-06-04 08:25:31 +02:00
|
|
|
session=session,
|
|
|
|
|
request=request,
|
|
|
|
|
response=response,
|
|
|
|
|
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
|
|
|
|
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
2026-07-10 13:31:57 +02:00
|
|
|
detail="Legacy DELETE /workstreams/{workstream_id}/dependencies/{dep_id} retired; "
|
|
|
|
|
"use DELETE /workplans/{workplan_id}/dependencies/{dep_id}",
|
2026-06-04 08:25:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@workplan_router.delete(
|
|
|
|
|
"/{workplan_id}/dependencies/{dep_id}",
|
|
|
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
|
|
|
)
|
|
|
|
|
async def delete_workplan_dependency(
|
|
|
|
|
workplan_id: uuid.UUID,
|
|
|
|
|
dep_id: uuid.UUID,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> None:
|
2026-06-22 13:52:13 +02:00
|
|
|
await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session)
|