Improved workplan dependency management facilities

This commit is contained in:
2026-05-04 11:45:24 +02:00
parent 4d6164e81b
commit bfed370a6e
10 changed files with 380 additions and 29 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"]