Improved workplan dependency management facilities
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import Date, ForeignKey, String, Text
|
from sqlalchemy import Date, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ class Workstream(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Text, UniqueConstraint
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -8,16 +8,36 @@ from api.models.base import Base, TimestampMixin, new_uuid
|
|||||||
|
|
||||||
|
|
||||||
class WorkstreamDependency(Base, TimestampMixin):
|
class WorkstreamDependency(Base, TimestampMixin):
|
||||||
"""Directed dependency edge: `from_workstream` depends on `to_workstream`.
|
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
|
||||||
|
|
||||||
Semantics: `to_workstream` must reach a satisfactory state before
|
Semantics: the target must reach a satisfactory state before `from_workstream`
|
||||||
`from_workstream` can fully proceed. Hard deletes are intentional —
|
can fully proceed. Hard deletes are intentional —
|
||||||
removing an edge removes a constraint, not information.
|
removing an edge removes a constraint, not information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "workstream_dependencies"
|
__tablename__ = "workstream_dependencies"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("from_workstream_id", "to_workstream_id", name="uq_ws_dep_pair"),
|
CheckConstraint(
|
||||||
|
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
||||||
|
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
||||||
|
name="ck_ws_dep_exactly_one_target",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_ws_dep_workstream_target",
|
||||||
|
"from_workstream_id",
|
||||||
|
"to_workstream_id",
|
||||||
|
"relationship_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("to_workstream_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_ws_dep_task_target",
|
||||||
|
"from_workstream_id",
|
||||||
|
"to_task_id",
|
||||||
|
"relationship_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("to_task_id IS NOT NULL"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -29,17 +49,27 @@ class WorkstreamDependency(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
to_workstream_id: Mapped[uuid.UUID] = mapped_column(
|
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
relationship_type: Mapped[str] = mapped_column(
|
||||||
|
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||||
|
)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
||||||
"Workstream", foreign_keys=[from_workstream_id]
|
"Workstream", foreign_keys=[from_workstream_id]
|
||||||
)
|
)
|
||||||
to_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
|
||||||
"Workstream", foreign_keys=[to_workstream_id]
|
"Workstream", foreign_keys=[to_workstream_id]
|
||||||
)
|
)
|
||||||
|
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||||
|
|||||||
@@ -97,9 +97,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
|
|||||||
|
|
||||||
# Build a slug+title lookup for all workstreams referenced in deps
|
# Build a slug+title lookup for all workstreams referenced in deps
|
||||||
dep_ws_ids = set()
|
dep_ws_ids = set()
|
||||||
|
dep_task_ids = set()
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
dep_ws_ids.add(d.from_workstream_id)
|
dep_ws_ids.add(d.from_workstream_id)
|
||||||
dep_ws_ids.add(d.to_workstream_id)
|
if d.to_workstream_id:
|
||||||
|
dep_ws_ids.add(d.to_workstream_id)
|
||||||
|
if d.to_task_id:
|
||||||
|
dep_task_ids.add(d.to_task_id)
|
||||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
@@ -108,22 +112,39 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
|
|||||||
)
|
)
|
||||||
for w in extra_rows.scalars():
|
for w in extra_rows.scalars():
|
||||||
ws_lookup[w.id] = w
|
ws_lookup[w.id] = w
|
||||||
|
task_lookup: dict = {}
|
||||||
|
if dep_task_ids:
|
||||||
|
task_rows = await session.execute(select(Task).where(Task.id.in_(dep_task_ids)))
|
||||||
|
task_lookup = {t.id: t for t in task_rows.scalars().all()}
|
||||||
|
|
||||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
from_id, to_id = d.from_workstream_id, d.to_workstream_id
|
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||||
if from_id in dep_index and to_id in ws_lookup:
|
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id,
|
dep_id=d.id,
|
||||||
|
target_type="workstream",
|
||||||
|
relationship_type=d.relationship_type,
|
||||||
workstream_id=to_id,
|
workstream_id=to_id,
|
||||||
workstream_slug=ws_lookup[to_id].slug,
|
workstream_slug=ws_lookup[to_id].slug,
|
||||||
workstream_title=ws_lookup[to_id].title,
|
workstream_title=ws_lookup[to_id].title,
|
||||||
description=d.description,
|
description=d.description,
|
||||||
))
|
))
|
||||||
if to_id in dep_index and from_id in ws_lookup:
|
if from_id in dep_index and task_id and task_id in task_lookup:
|
||||||
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
|
dep_id=d.id,
|
||||||
|
target_type="task",
|
||||||
|
relationship_type=d.relationship_type,
|
||||||
|
task_id=task_id,
|
||||||
|
task_title=task_lookup[task_id].title,
|
||||||
|
description=d.description,
|
||||||
|
))
|
||||||
|
if to_id and to_id in dep_index and from_id in ws_lookup:
|
||||||
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
|
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id,
|
dep_id=d.id,
|
||||||
|
target_type="workstream",
|
||||||
|
relationship_type=d.relationship_type,
|
||||||
workstream_id=from_id,
|
workstream_id=from_id,
|
||||||
workstream_slug=ws_lookup[from_id].slug,
|
workstream_slug=ws_lookup[from_id].slug,
|
||||||
workstream_title=ws_lookup[from_id].title,
|
workstream_title=ws_lookup[from_id].title,
|
||||||
@@ -142,7 +163,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": ws_lookup[d.to_workstream_id].status}
|
{"workstation": ws_lookup[d.to_workstream_id].status}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id in ws_lookup
|
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workstream import Workstream
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workstream_dependency import WorkstreamDependency
|
||||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
||||||
@@ -22,17 +23,27 @@ async def create_dependency(
|
|||||||
body: WorkstreamDependencyCreate,
|
body: WorkstreamDependencyCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkstreamDependency:
|
) -> WorkstreamDependency:
|
||||||
"""Record that workstream_id depends on body.to_workstream_id."""
|
"""Record that workstream_id depends on another workstream or a task."""
|
||||||
if await session.get(Workstream, workstream_id) is None:
|
if await session.get(Workstream, workstream_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="from workstream not found")
|
raise HTTPException(status_code=404, detail="from workstream not found")
|
||||||
if await session.get(Workstream, body.to_workstream_id) is None:
|
|
||||||
raise HTTPException(status_code=404, detail="to workstream not found")
|
has_workstream_target = body.to_workstream_id is not None
|
||||||
|
has_task_target = body.to_task_id is not None
|
||||||
|
if has_workstream_target == has_task_target:
|
||||||
|
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
||||||
|
|
||||||
|
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="target workstream not found")
|
||||||
|
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")
|
||||||
if workstream_id == body.to_workstream_id:
|
if workstream_id == body.to_workstream_id:
|
||||||
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
|
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
|
||||||
|
|
||||||
dep = WorkstreamDependency(
|
dep = WorkstreamDependency(
|
||||||
from_workstream_id=workstream_id,
|
from_workstream_id=workstream_id,
|
||||||
to_workstream_id=body.to_workstream_id,
|
to_workstream_id=body.to_workstream_id,
|
||||||
|
to_task_id=body.to_task_id,
|
||||||
|
relationship_type=body.relationship_type,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
)
|
)
|
||||||
session.add(dep)
|
session.add(dep)
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ async def list_workstreams(
|
|||||||
q = q.where(Workstream.owner == owner)
|
q = q.where(Workstream.owner == owner)
|
||||||
if slug:
|
if slug:
|
||||||
q = q.where(Workstream.slug == slug)
|
q = q.where(Workstream.slug == slug)
|
||||||
q = q.order_by(Workstream.updated_at.desc())
|
q = q.order_by(
|
||||||
|
Workstream.planning_priority.asc().nullslast(),
|
||||||
|
Workstream.planning_order.asc().nullslast(),
|
||||||
|
Workstream.updated_at.desc(),
|
||||||
|
)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class WorkstreamCreate(BaseModel):
|
|||||||
status: WorkstreamStatus = "active"
|
status: WorkstreamStatus = "active"
|
||||||
owner: str | None = None
|
owner: str | None = None
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
||||||
repo_goal_id: uuid.UUID | None = None
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@ class WorkstreamUpdate(BaseModel):
|
|||||||
status: WorkstreamStatus | None = None
|
status: WorkstreamStatus | None = None
|
||||||
owner: str | None = None
|
owner: str | None = None
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
repo_goal_id: uuid.UUID | None = None
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
@@ -43,6 +47,8 @@ class WorkstreamRead(BaseModel):
|
|||||||
status: WorkstreamStatus
|
status: WorkstreamStatus
|
||||||
owner: str | None = None
|
owner: str | None = None
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
|
|
||||||
|
|
||||||
class WorkstreamDependencyCreate(BaseModel):
|
class WorkstreamDependencyCreate(BaseModel):
|
||||||
to_workstream_id: uuid.UUID
|
to_workstream_id: uuid.UUID | None = None
|
||||||
|
to_task_id: uuid.UUID | None = None
|
||||||
|
relationship_type: str = "blocks"
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +15,9 @@ class WorkstreamDependencyRead(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
from_workstream_id: uuid.UUID
|
from_workstream_id: uuid.UUID
|
||||||
to_workstream_id: uuid.UUID
|
to_workstream_id: uuid.UUID | None = None
|
||||||
|
to_task_id: uuid.UUID | None = None
|
||||||
|
relationship_type: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -22,7 +26,11 @@ class WorkstreamDependencyRead(BaseModel):
|
|||||||
class WorkstreamDepStub(BaseModel):
|
class WorkstreamDepStub(BaseModel):
|
||||||
"""Minimal projection of the other end of a dependency edge."""
|
"""Minimal projection of the other end of a dependency edge."""
|
||||||
dep_id: uuid.UUID
|
dep_id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
target_type: str = "workstream"
|
||||||
workstream_slug: str
|
relationship_type: str = "blocks"
|
||||||
workstream_title: str
|
workstream_id: uuid.UUID | None = None
|
||||||
|
workstream_slug: str | None = None
|
||||||
|
workstream_title: str | None = None
|
||||||
|
task_id: uuid.UUID | None = None
|
||||||
|
task_title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|||||||
@@ -419,6 +419,8 @@ def create_workstream(
|
|||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
due_date: str | None = None,
|
due_date: str | None = None,
|
||||||
repo_id: str | None = None,
|
repo_id: str | None = None,
|
||||||
|
planning_priority: str | None = None,
|
||||||
|
planning_order: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new workstream under a topic and emit a progress_event.
|
"""Create a new workstream under a topic and emit a progress_event.
|
||||||
|
|
||||||
@@ -430,6 +432,8 @@ def create_workstream(
|
|||||||
owner: optional owner name
|
owner: optional owner name
|
||||||
due_date: optional ISO date string (YYYY-MM-DD)
|
due_date: optional ISO date string (YYYY-MM-DD)
|
||||||
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
|
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
|
||||||
|
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
|
||||||
|
planning_order: optional numeric ordering hint inside a repo/domain
|
||||||
"""
|
"""
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
||||||
@@ -442,6 +446,8 @@ def create_workstream(
|
|||||||
"due_date": due_date,
|
"due_date": due_date,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"repo_id": repo_id,
|
"repo_id": repo_id,
|
||||||
|
"planning_priority": planning_priority,
|
||||||
|
"planning_order": planning_order,
|
||||||
})
|
})
|
||||||
_post("/progress", {
|
_post("/progress", {
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
@@ -830,21 +836,27 @@ def get_next_steps() -> str:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_dependency(
|
def create_dependency(
|
||||||
from_workstream_id: str,
|
from_workstream_id: str,
|
||||||
to_workstream_id: str,
|
to_workstream_id: str | None = None,
|
||||||
|
to_task_id: str | None = None,
|
||||||
|
relationship_type: str = "blocks",
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Record that one workstream depends on another.
|
"""Record that one workstream depends on another workstream or task.
|
||||||
|
|
||||||
Semantics: from_workstream cannot fully proceed until to_workstream reaches
|
Semantics: from_workstream cannot fully proceed until the target reaches
|
||||||
a satisfactory state.
|
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
from_workstream_id: UUID of the workstream that has the dependency
|
from_workstream_id: UUID of the workstream that has the dependency
|
||||||
to_workstream_id: UUID of the workstream it depends on
|
to_workstream_id: UUID of the workstream it depends on
|
||||||
|
to_task_id: UUID of the task it depends on
|
||||||
|
relationship_type: blocks | starts_after | informs | soft_dependency
|
||||||
description: optional human-readable explanation of the dependency
|
description: optional human-readable explanation of the dependency
|
||||||
"""
|
"""
|
||||||
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
|
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
|
||||||
"to_workstream_id": to_workstream_id,
|
"to_workstream_id": to_workstream_id,
|
||||||
|
"to_task_id": to_task_id,
|
||||||
|
"relationship_type": relationship_type,
|
||||||
"description": description,
|
"description": description,
|
||||||
})
|
})
|
||||||
return json.dumps(dep, indent=2)
|
return json.dumps(dep, indent=2)
|
||||||
@@ -862,7 +874,7 @@ def list_dependencies(workstream_id: str) -> str:
|
|||||||
"""
|
"""
|
||||||
edges = _get(f"/workstreams/{workstream_id}/dependencies")
|
edges = _get(f"/workstreams/{workstream_id}/dependencies")
|
||||||
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
|
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
|
||||||
blocks = [e for e in edges if e["to_workstream_id"] == workstream_id]
|
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id]
|
||||||
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
|
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""add workstream planning metadata and typed dependency targets
|
||||||
|
|
||||||
|
Revision ID: s6n7o8p9q0r1
|
||||||
|
Revises: r5m6n7o8p9q0
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "s6n7o8p9q0r1"
|
||||||
|
down_revision = "r5m6n7o8p9q0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("workstreams", sa.Column("planning_priority", sa.String(length=20), nullable=True))
|
||||||
|
op.add_column("workstreams", sa.Column("planning_order", sa.Integer(), nullable=True))
|
||||||
|
op.create_index("ix_workstreams_planning_priority", "workstreams", ["planning_priority"])
|
||||||
|
op.create_index("ix_workstreams_planning_order", "workstreams", ["planning_order"])
|
||||||
|
|
||||||
|
op.drop_constraint("uq_ws_dep_pair", "workstream_dependencies", type_="unique")
|
||||||
|
op.add_column("workstream_dependencies", sa.Column("to_task_id", UUID(as_uuid=True), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"workstream_dependencies",
|
||||||
|
sa.Column("relationship_type", sa.String(length=40), nullable=False, server_default="blocks"),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"workstream_dependencies",
|
||||||
|
"to_workstream_id",
|
||||||
|
existing_type=UUID(as_uuid=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_ws_dep_to_task_id",
|
||||||
|
"workstream_dependencies",
|
||||||
|
"tasks",
|
||||||
|
["to_task_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_index("ix_workstream_dependencies_to_task_id", "workstream_dependencies", ["to_task_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_workstream_dependencies_relationship_type",
|
||||||
|
"workstream_dependencies",
|
||||||
|
["relationship_type"],
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_ws_dep_exactly_one_target",
|
||||||
|
"workstream_dependencies",
|
||||||
|
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
||||||
|
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_ws_dep_workstream_target",
|
||||||
|
"workstream_dependencies",
|
||||||
|
["from_workstream_id", "to_workstream_id", "relationship_type"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("to_workstream_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_ws_dep_task_target",
|
||||||
|
"workstream_dependencies",
|
||||||
|
["from_workstream_id", "to_task_id", "relationship_type"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("to_task_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_ws_dep_task_target", table_name="workstream_dependencies")
|
||||||
|
op.drop_index("uq_ws_dep_workstream_target", table_name="workstream_dependencies")
|
||||||
|
op.drop_constraint("ck_ws_dep_exactly_one_target", "workstream_dependencies", type_="check")
|
||||||
|
op.drop_index("ix_workstream_dependencies_relationship_type", table_name="workstream_dependencies")
|
||||||
|
op.drop_index("ix_workstream_dependencies_to_task_id", table_name="workstream_dependencies")
|
||||||
|
op.drop_constraint("fk_ws_dep_to_task_id", "workstream_dependencies", type_="foreignkey")
|
||||||
|
op.alter_column(
|
||||||
|
"workstream_dependencies",
|
||||||
|
"to_workstream_id",
|
||||||
|
existing_type=UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.drop_column("workstream_dependencies", "relationship_type")
|
||||||
|
op.drop_column("workstream_dependencies", "to_task_id")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_ws_dep_pair",
|
||||||
|
"workstream_dependencies",
|
||||||
|
["from_workstream_id", "to_workstream_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index("ix_workstreams_planning_order", table_name="workstreams")
|
||||||
|
op.drop_index("ix_workstreams_planning_priority", table_name="workstreams")
|
||||||
|
op.drop_column("workstreams", "planning_order")
|
||||||
|
op.drop_column("workstreams", "planning_priority")
|
||||||
@@ -22,6 +22,8 @@ Checks:
|
|||||||
C-15 task-db-ahead WARN Yes DB task status is ahead of file — regression prevented; writeback syncs file
|
C-15 task-db-ahead WARN Yes DB task status is ahead of file — regression prevented; writeback syncs file
|
||||||
C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress
|
C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress
|
||||||
C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence
|
C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence
|
||||||
|
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
|
||||||
|
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
||||||
@@ -69,6 +71,7 @@ _ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
|||||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
||||||
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||||
|
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
||||||
|
|
||||||
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
|
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
|
||||||
# "completed". This map translates file values to DB values before comparison
|
# "completed". This map translates file values to DB values before comparison
|
||||||
@@ -214,6 +217,25 @@ def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item).strip().strip('"') for item in value if str(item).strip()]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip().strip('"') for item in value.split(",") if item.strip()]
|
||||||
|
return [str(value).strip().strip('"')]
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int_or_none(value: Any) -> int | None:
|
||||||
|
if value in (None, "", "~", "null", "None", "none"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# File update helpers
|
# File update helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -511,6 +533,22 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
if wp_file.parent == workplans_dir:
|
if wp_file.parent == workplans_dir:
|
||||||
active_file_ws_ids.add(ws_id)
|
active_file_ws_ids.add(ws_id)
|
||||||
|
|
||||||
|
workplan_id_to_ws_id: dict[str, str] = {}
|
||||||
|
task_file_id_to_sh_id: dict[str, str] = {}
|
||||||
|
for _wp_file, meta, body in workplan_infos:
|
||||||
|
mapped_ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"')
|
||||||
|
wp_id = str(meta.get("id", "")).strip()
|
||||||
|
if wp_id and mapped_ws_id:
|
||||||
|
workplan_id_to_ws_id[wp_id] = mapped_ws_id
|
||||||
|
for task in get_tasks_from_workplan(meta, body):
|
||||||
|
if task.get("_parse_error"):
|
||||||
|
continue
|
||||||
|
task_file_id = str(task.get("id", "")).strip()
|
||||||
|
raw_sh = task.get("state_hub_task_id")
|
||||||
|
task_sh_id = "" if raw_sh is None else str(raw_sh).strip().strip('"')
|
||||||
|
if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"):
|
||||||
|
task_file_id_to_sh_id[task_file_id] = task_sh_id
|
||||||
|
|
||||||
# Per-workplan checks
|
# Per-workplan checks
|
||||||
for wp_file, meta, body in workplan_infos:
|
for wp_file, meta, body in workplan_infos:
|
||||||
fname = workplan_display_path(repo_dir, wp_file)
|
fname = workplan_display_path(repo_dir, wp_file)
|
||||||
@@ -618,6 +656,38 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
_fix_context={"ws_id": ws_id, "field": "title", "value": file_title},
|
_fix_context={"ws_id": ws_id, "field": "title", "value": file_title},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
planning_priority = str(meta.get("planning_priority", "")).strip() or None
|
||||||
|
if planning_priority != (ws.get("planning_priority") or None):
|
||||||
|
report.add(
|
||||||
|
severity="WARN", check_id="C-19",
|
||||||
|
message=(
|
||||||
|
f"Planning priority drift in '{ws.get('slug')}': "
|
||||||
|
f"file={planning_priority!r} db={ws.get('planning_priority')!r} (file wins)"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=planning_priority,
|
||||||
|
db_value=ws.get("planning_priority"),
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={"ws_id": ws_id, "field": "planning_priority", "value": planning_priority},
|
||||||
|
)
|
||||||
|
|
||||||
|
planning_order = _as_int_or_none(meta.get("planning_order"))
|
||||||
|
if planning_order != ws.get("planning_order"):
|
||||||
|
report.add(
|
||||||
|
severity="WARN", check_id="C-19",
|
||||||
|
message=(
|
||||||
|
f"Planning order drift in '{ws.get('slug')}': "
|
||||||
|
f"file={planning_order!r} db={ws.get('planning_order')!r} (file wins)"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=planning_order,
|
||||||
|
db_value=ws.get("planning_order"),
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order},
|
||||||
|
)
|
||||||
|
|
||||||
# C-10, C-11, C-12: task-level checks
|
# C-10, C-11, C-12: task-level checks
|
||||||
tasks = get_tasks_from_workplan(meta, body)
|
tasks = get_tasks_from_workplan(meta, body)
|
||||||
db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id})
|
db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id})
|
||||||
@@ -626,6 +696,76 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
for t in db_tasks:
|
for t in db_tasks:
|
||||||
db_task_by_id[t["id"]] = t
|
db_task_by_id[t["id"]] = t
|
||||||
|
|
||||||
|
existing_deps = _api_get(api_base, f"/workstreams/{ws_id}/dependencies") or []
|
||||||
|
existing_dep_keys = set()
|
||||||
|
if isinstance(existing_deps, list):
|
||||||
|
for dep in existing_deps:
|
||||||
|
if dep.get("from_workstream_id") != ws_id:
|
||||||
|
continue
|
||||||
|
rel = dep.get("relationship_type") or "blocks"
|
||||||
|
if dep.get("to_workstream_id"):
|
||||||
|
existing_dep_keys.add(("workstream", dep["to_workstream_id"], rel))
|
||||||
|
if dep.get("to_task_id"):
|
||||||
|
existing_dep_keys.add(("task", dep["to_task_id"], rel))
|
||||||
|
|
||||||
|
for target_wp_id in _as_list(meta.get("depends_on_workplans")):
|
||||||
|
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
|
||||||
|
if not target_ws_id:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-20",
|
||||||
|
message=f"Workplan dependency target '{target_wp_id}' is not linked to State Hub",
|
||||||
|
file_path=fname,
|
||||||
|
file_value=target_wp_id,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
dep_key = ("workstream", target_ws_id, "blocks")
|
||||||
|
if dep_key not in existing_dep_keys:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-20",
|
||||||
|
message=f"Missing DB dependency edge: {ws_id[:8]}… depends on workplan {target_wp_id}",
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=target_wp_id,
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={
|
||||||
|
"from_workstream_id": ws_id,
|
||||||
|
"to_workstream_id": target_ws_id,
|
||||||
|
"relationship_type": "blocks",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for target_task_id in _as_list(meta.get("depends_on_tasks")):
|
||||||
|
target_sh_id = task_file_id_to_sh_id.get(target_task_id)
|
||||||
|
if not target_sh_id:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-20",
|
||||||
|
message=f"Task dependency target '{target_task_id}' is not linked to State Hub",
|
||||||
|
file_path=fname,
|
||||||
|
file_value=target_task_id,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
dep_key = ("task", target_sh_id, "starts_after")
|
||||||
|
if dep_key not in existing_dep_keys:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-20",
|
||||||
|
message=f"Missing DB dependency edge: {ws_id[:8]}… starts after task {target_task_id}",
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=target_task_id,
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={
|
||||||
|
"from_workstream_id": ws_id,
|
||||||
|
"to_task_id": target_sh_id,
|
||||||
|
"relationship_type": "starts_after",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
file_task_sh_ids: set[str] = set()
|
file_task_sh_ids: set[str] = set()
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
@@ -1180,7 +1320,7 @@ def fix_repo(
|
|||||||
for issue in fixable:
|
for issue in fixable:
|
||||||
ctx = issue._fix_context
|
ctx = issue._fix_context
|
||||||
try:
|
try:
|
||||||
if issue.check_id in ("C-04", "C-05", "C-13"):
|
if issue.check_id in ("C-04", "C-05", "C-13", "C-19"):
|
||||||
ws_id = ctx["ws_id"]
|
ws_id = ctx["ws_id"]
|
||||||
result = _api_patch(api_base, f"/workstreams/{ws_id}",
|
result = _api_patch(api_base, f"/workstreams/{ws_id}",
|
||||||
{ctx["field"]: ctx["value"]})
|
{ctx["field"]: ctx["value"]})
|
||||||
@@ -1229,6 +1369,8 @@ def fix_repo(
|
|||||||
"title": title or wp_id,
|
"title": title or wp_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"owner": str(meta.get("owner", "")).strip() or None,
|
"owner": str(meta.get("owner", "")).strip() or None,
|
||||||
|
"planning_priority": str(meta.get("planning_priority", "")).strip() or None,
|
||||||
|
"planning_order": _as_int_or_none(meta.get("planning_order")),
|
||||||
})
|
})
|
||||||
if ws_data is None:
|
if ws_data is None:
|
||||||
report.fixes_applied.append(
|
report.fixes_applied.append(
|
||||||
@@ -1284,6 +1426,25 @@ def fix_repo(
|
|||||||
f"repo_id → {correct_repo_id[:8]}…"
|
f"repo_id → {correct_repo_id[:8]}…"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif issue.check_id == "C-20":
|
||||||
|
from_workstream_id = ctx["from_workstream_id"]
|
||||||
|
body = {
|
||||||
|
"to_workstream_id": ctx.get("to_workstream_id"),
|
||||||
|
"to_task_id": ctx.get("to_task_id"),
|
||||||
|
"relationship_type": ctx["relationship_type"],
|
||||||
|
}
|
||||||
|
result = _api_post(api_base, f"/workstreams/{from_workstream_id}/dependencies", body)
|
||||||
|
if result is not None and "_error" not in result:
|
||||||
|
target = ctx.get("to_workstream_id") or ctx.get("to_task_id")
|
||||||
|
report.fixes_applied.append(
|
||||||
|
f"C-20 fixed: dependency {from_workstream_id[:8]}… "
|
||||||
|
f"{ctx['relationship_type']} → {target[:8]}…"
|
||||||
|
)
|
||||||
|
elif result is not None:
|
||||||
|
report.fixes_applied.append(
|
||||||
|
f"C-20 FAILED: {result['_error']}"
|
||||||
|
)
|
||||||
|
|
||||||
elif issue.check_id == "C-10":
|
elif issue.check_id == "C-10":
|
||||||
task_id = ctx["task_id"]
|
task_id = ctx["task_id"]
|
||||||
status = ctx["status"]
|
status = ctx["status"]
|
||||||
|
|||||||
Reference in New Issue
Block a user