diff --git a/state-hub/api/models/workstream.py b/state-hub/api/models/workstream.py index db45d16..9b3223b 100644 --- a/state-hub/api/models/workstream.py +++ b/state-hub/api/models/workstream.py @@ -1,7 +1,7 @@ import uuid 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.orm import Mapped, mapped_column, relationship @@ -25,6 +25,8 @@ class Workstream(Base, TimestampMixin): ) owner: Mapped[str | None] = mapped_column(String(100), 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( UUID(as_uuid=True), diff --git a/state-hub/api/models/workstream_dependency.py b/state-hub/api/models/workstream_dependency.py index e0ebcd4..31a192e 100644 --- a/state-hub/api/models/workstream_dependency.py +++ b/state-hub/api/models/workstream_dependency.py @@ -1,6 +1,6 @@ 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.orm import Mapped, mapped_column, relationship @@ -8,16 +8,36 @@ from api.models.base import Base, TimestampMixin, new_uuid 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 - `from_workstream` can fully proceed. Hard deletes are intentional — + Semantics: the target must reach a satisfactory state before `from_workstream` + can fully proceed. Hard deletes are intentional — removing an edge removes a constraint, not information. """ __tablename__ = "workstream_dependencies" __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( @@ -29,17 +49,27 @@ class WorkstreamDependency(Base, TimestampMixin): nullable=False, index=True, ) - to_workstream_id: Mapped[uuid.UUID] = mapped_column( + to_workstream_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="CASCADE"), - nullable=False, + nullable=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) from_workstream: Mapped["Workstream"] = relationship( # noqa: F821 "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] ) + to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821 diff --git a/state-hub/api/routers/state.py b/state-hub/api/routers/state.py index 17b3988..5c18ec6 100644 --- a/state-hub/api/routers/state.py +++ b/state-hub/api/routers/state.py @@ -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 dep_ws_ids = set() + dep_task_ids = set() for d in dep_rows: 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} extra_ids = dep_ws_ids - set(ws_lookup.keys()) if extra_ids: @@ -108,22 +112,39 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ) for w in extra_rows.scalars(): 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) dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} for d in dep_rows: - from_id, to_id = d.from_workstream_id, d.to_workstream_id - if from_id in dep_index and to_id in ws_lookup: + 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 and to_id in ws_lookup: dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_id=d.id, + target_type="workstream", + relationship_type=d.relationship_type, workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug, workstream_title=ws_lookup[to_id].title, 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_id=d.id, + target_type="workstream", + relationship_type=d.relationship_type, workstream_id=from_id, workstream_slug=ws_lookup[from_id].slug, workstream_title=ws_lookup[from_id].title, @@ -142,7 +163,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm "dependencies": [ {"workstation": ws_lookup[d.to_workstream_id].status} 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) diff --git a/state-hub/api/routers/workstream_dependencies.py b/state-hub/api/routers/workstream_dependencies.py index 35433cf..6821f6d 100644 --- a/state-hub/api/routers/workstream_dependencies.py +++ b/state-hub/api/routers/workstream_dependencies.py @@ -5,6 +5,7 @@ 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.models.workstream_dependency import WorkstreamDependency from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead @@ -22,17 +23,27 @@ async def create_dependency( body: WorkstreamDependencyCreate, session: AsyncSession = Depends(get_session), ) -> 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: 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: raise HTTPException(status_code=422, detail="a workstream cannot depend on itself") dep = WorkstreamDependency( from_workstream_id=workstream_id, to_workstream_id=body.to_workstream_id, + to_task_id=body.to_task_id, + relationship_type=body.relationship_type, description=body.description, ) session.add(dep) diff --git a/state-hub/api/routers/workstreams.py b/state-hub/api/routers/workstreams.py index 701739d..de097d0 100644 --- a/state-hub/api/routers/workstreams.py +++ b/state-hub/api/routers/workstreams.py @@ -82,7 +82,11 @@ async def list_workstreams( q = q.where(Workstream.owner == owner) if 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) return list(result.scalars().all()) diff --git a/state-hub/api/schemas/workstream.py b/state-hub/api/schemas/workstream.py index 77e4e2e..26e1698 100644 --- a/state-hub/api/schemas/workstream.py +++ b/state-hub/api/schemas/workstream.py @@ -17,6 +17,8 @@ class WorkstreamCreate(BaseModel): status: WorkstreamStatus = "active" owner: str | 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_goal_id: uuid.UUID | None = None @@ -27,6 +29,8 @@ class WorkstreamUpdate(BaseModel): status: WorkstreamStatus | None = None owner: str | None = None due_date: date | None = None + planning_priority: str | None = None + planning_order: int | None = None repo_id: uuid.UUID | None = None repo_goal_id: uuid.UUID | None = None @@ -43,6 +47,8 @@ class WorkstreamRead(BaseModel): status: WorkstreamStatus owner: str | None = None due_date: date | None = None + planning_priority: str | None = None + planning_order: int | None = None created_at: datetime updated_at: datetime diff --git a/state-hub/api/schemas/workstream_dependency.py b/state-hub/api/schemas/workstream_dependency.py index ad6ce89..ec17ed7 100644 --- a/state-hub/api/schemas/workstream_dependency.py +++ b/state-hub/api/schemas/workstream_dependency.py @@ -5,7 +5,9 @@ from pydantic import BaseModel, ConfigDict 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 @@ -13,7 +15,9 @@ class WorkstreamDependencyRead(BaseModel): model_config = ConfigDict(from_attributes=True) 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 created_at: datetime updated_at: datetime @@ -22,7 +26,11 @@ class WorkstreamDependencyRead(BaseModel): class WorkstreamDepStub(BaseModel): """Minimal projection of the other end of a dependency edge.""" dep_id: uuid.UUID - workstream_id: uuid.UUID - workstream_slug: str - workstream_title: str + target_type: str = "workstream" + relationship_type: str = "blocks" + 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 diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index f42b90b..fefbf56 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -419,6 +419,8 @@ def create_workstream( owner: str | None = None, due_date: str | None = None, repo_id: str | None = None, + planning_priority: str | None = None, + planning_order: int | None = None, ) -> str: """Create a new workstream under a topic and emit a progress_event. @@ -430,6 +432,8 @@ def create_workstream( owner: optional owner name due_date: optional ISO date string (YYYY-MM-DD) 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: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") @@ -442,6 +446,8 @@ def create_workstream( "due_date": due_date, "status": "active", "repo_id": repo_id, + "planning_priority": planning_priority, + "planning_order": planning_order, }) _post("/progress", { "topic_id": topic_id, @@ -830,21 +836,27 @@ def get_next_steps() -> str: @mcp.tool() def create_dependency( 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, ) -> 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 - a satisfactory state. + Semantics: from_workstream cannot fully proceed until the target reaches + a satisfactory state. Provide exactly one of to_workstream_id or to_task_id. Args: from_workstream_id: UUID of the workstream that has the dependency 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 """ dep = _post(f"/workstreams/{from_workstream_id}/dependencies", { "to_workstream_id": to_workstream_id, + "to_task_id": to_task_id, + "relationship_type": relationship_type, "description": description, }) return json.dumps(dep, indent=2) @@ -862,7 +874,7 @@ def list_dependencies(workstream_id: str) -> str: """ edges = _get(f"/workstreams/{workstream_id}/dependencies") 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) diff --git a/state-hub/migrations/versions/s6n7o8p9q0r1_workstream_planning_metadata.py b/state-hub/migrations/versions/s6n7o8p9q0r1_workstream_planning_metadata.py new file mode 100644 index 0000000..933f288 --- /dev/null +++ b/state-hub/migrations/versions/s6n7o8p9q0r1_workstream_planning_metadata.py @@ -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") diff --git a/state-hub/scripts/consistency_check.py b/state-hub/scripts/consistency_check.py index 037ad74..61a9d20 100644 --- a/state-hub/scripts/consistency_check.py +++ b/state-hub/scripts/consistency_check.py @@ -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-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-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: 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_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} 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 # "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 [] +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 # --------------------------------------------------------------------------- @@ -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: 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 for wp_file, meta, body in workplan_infos: 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}, ) + 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 tasks = get_tasks_from_workplan(meta, body) 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: 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() for task in tasks: @@ -1180,7 +1320,7 @@ def fix_repo( for issue in fixable: ctx = issue._fix_context 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"] result = _api_patch(api_base, f"/workstreams/{ws_id}", {ctx["field"]: ctx["value"]}) @@ -1229,6 +1369,8 @@ def fix_repo( "title": title or wp_id, "status": status, "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: report.fixes_applied.append( @@ -1284,6 +1426,25 @@ def fix_repo( 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": task_id = ctx["task_id"] status = ctx["status"]