Index workplan flavor and omit residuals from default views.
STATE-WP-0092: persist flavor from files, treat depends_on as the C-20 canonical edge, exclude flavor=residual from summary/next_steps/deps unless include_residuals is set. Live primary still needs the alembic revision applied. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
parent
ea11451e9c
commit
ddc3338541
18 changed files with 852 additions and 25 deletions
|
|
@ -43,9 +43,12 @@ with `make -C ~/state-hub configure-codex WITH_MCP=1`.
|
||||||
# Offline brief — works without hub connection
|
# Offline brief — works without hub connection
|
||||||
cat .custodian-brief.md
|
cat .custodian-brief.md
|
||||||
|
|
||||||
# Active workplans for this domain
|
# Active workplans for this domain (default summary omits flavor: residual)
|
||||||
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||||
| python3 -m json.tool
|
| python3 -m json.tool
|
||||||
|
# Residuals are not implementation demand unless promoted:
|
||||||
|
# GET /state/summary (open_workplans omits flavor=residual)
|
||||||
|
# GET /workplans/?flavor=residual
|
||||||
|
|
||||||
# Check inbox
|
# Check inbox
|
||||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,8 @@ use `/state/health`, not `/state/summary`.
|
||||||
| `/state/summary` | Full snapshot |
|
| `/state/summary` | Full snapshot |
|
||||||
| `/state/health` | DB connectivity check |
|
| `/state/health` | DB connectivity check |
|
||||||
|
|
||||||
See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
|
See `docs/work-record-flavor.md` for flavor buckets and residual
|
||||||
|
default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
|
||||||
compatibility policy and retirement criteria.
|
compatibility policy and retirement criteria.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class Task(Base, TimestampMixin):
|
||||||
# title, so renaming a heading looks like one task vanishing and another
|
# title, so renaming a heading looks like one task vanishing and another
|
||||||
# appearing.
|
# appearing.
|
||||||
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||||
|
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
status: Mapped[TaskStatus] = mapped_column(
|
status: Mapped[TaskStatus] = mapped_column(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin):
|
||||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
|
flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -75,6 +75,12 @@ def _dual_workplan_refs(
|
||||||
"workstream_slug": slug,
|
"workstream_slug": slug,
|
||||||
}
|
}
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||||
|
from api.work_record_flavor import (
|
||||||
|
RESIDUAL_FLAVOR,
|
||||||
|
WORK_RECORD_FLAVORS,
|
||||||
|
is_residual_flavor,
|
||||||
|
normalize_flavor,
|
||||||
|
)
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
CLOSED_WORKPLAN_STATUSES,
|
CLOSED_WORKPLAN_STATUSES,
|
||||||
OPEN_WORKPLAN_STATUSES,
|
OPEN_WORKPLAN_STATUSES,
|
||||||
|
|
@ -100,12 +106,44 @@ def _summary_cache_headers(
|
||||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
|
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_summary_flavor_view(
|
||||||
|
summary: StateSummary,
|
||||||
|
*,
|
||||||
|
include_residuals: bool,
|
||||||
|
flavor: str | None,
|
||||||
|
) -> StateSummary:
|
||||||
|
"""Default views omit residual workplans; totals.residual_open stays honest."""
|
||||||
|
wanted = normalize_flavor(flavor)
|
||||||
|
if wanted is not None and wanted not in WORK_RECORD_FLAVORS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Unknown work-record flavor {wanted!r}; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR
|
||||||
|
residual_rows = list(summary.residual_open_workplans)
|
||||||
|
open_rows = list(summary.open_workplans)
|
||||||
|
if wanted is not None:
|
||||||
|
open_rows = [row for row in open_rows if row.flavor == wanted]
|
||||||
|
residual_rows = [row for row in residual_rows if row.flavor == wanted]
|
||||||
|
return summary.model_copy(
|
||||||
|
update={
|
||||||
|
"open_workplans": open_rows,
|
||||||
|
"residual_open_workplans": residual_rows if show_residuals else [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=StateSummary)
|
@router.get("/summary", response_model=StateSummary)
|
||||||
async def get_summary(
|
async def get_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
refresh: bool = False,
|
refresh: bool = False,
|
||||||
|
include_residuals: bool = Query(False),
|
||||||
|
flavor: str | None = Query(None),
|
||||||
) -> StateSummary:
|
) -> StateSummary:
|
||||||
revision = await fetch_summary_revision(session)
|
revision = await fetch_summary_revision(session)
|
||||||
revision_token = revision.combined_fingerprint()
|
revision_token = revision.combined_fingerprint()
|
||||||
|
|
@ -116,22 +154,38 @@ async def get_summary(
|
||||||
|
|
||||||
if cache_status == "hit-revision" and cached is not None:
|
if cache_status == "hit-revision" and cached is not None:
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
|
return _apply_summary_flavor_view(
|
||||||
|
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||||
|
include_residuals=include_residuals,
|
||||||
|
flavor=flavor,
|
||||||
|
)
|
||||||
|
|
||||||
if cache_status == "progress-section" and cached is not None:
|
if cache_status == "progress-section" and cached is not None:
|
||||||
result = await apply_progress_section(session, cached, revision)
|
result = await apply_progress_section(session, cached, revision)
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
return result.model_copy(update={"ops_runs": await get_ops_run_projection()})
|
return _apply_summary_flavor_view(
|
||||||
|
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||||
|
include_residuals=include_residuals,
|
||||||
|
flavor=flavor,
|
||||||
|
)
|
||||||
|
|
||||||
if cache_status == "stale" and cached is not None:
|
if cache_status == "stale" and cached is not None:
|
||||||
cache.schedule_refresh(revision)
|
cache.schedule_refresh(revision)
|
||||||
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
||||||
return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
|
return _apply_summary_flavor_view(
|
||||||
|
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||||
|
include_residuals=include_residuals,
|
||||||
|
flavor=flavor,
|
||||||
|
)
|
||||||
|
|
||||||
result = await build_state_summary(session)
|
result = await build_state_summary(session)
|
||||||
cache.store(result, revision)
|
cache.store(result, revision)
|
||||||
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
||||||
return result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)})
|
return _apply_summary_flavor_view(
|
||||||
|
result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
|
||||||
|
include_residuals=include_residuals,
|
||||||
|
flavor=flavor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def build_state_summary(session: AsyncSession) -> StateSummary:
|
async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
|
|
@ -336,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
),
|
),
|
||||||
archived=ws_counts.get("archived", 0),
|
archived=ws_counts.get("archived", 0),
|
||||||
total=sum(ws_counts.values()),
|
total=sum(ws_counts.values()),
|
||||||
|
residual_open=0,
|
||||||
),
|
),
|
||||||
tasks=TaskTotals(
|
tasks=TaskTotals(
|
||||||
wait=task_counts.get(TaskStatus.wait, 0),
|
wait=task_counts.get(TaskStatus.wait, 0),
|
||||||
|
|
@ -397,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
)
|
)
|
||||||
for w in open_ws
|
for w in open_ws
|
||||||
]
|
]
|
||||||
|
relevant_rows = [
|
||||||
|
row for row, w in zip(open_workplan_rows, open_ws)
|
||||||
|
if not is_residual_flavor(w.flavor)
|
||||||
|
]
|
||||||
|
residual_rows = [
|
||||||
|
row for row, w in zip(open_workplan_rows, open_ws)
|
||||||
|
if is_residual_flavor(w.flavor)
|
||||||
|
]
|
||||||
|
totals.workstreams.residual_open = len(residual_rows)
|
||||||
|
|
||||||
result = StateSummary(
|
result = StateSummary(
|
||||||
generated_at=datetime.now(tz=timezone.utc),
|
generated_at=datetime.now(tz=timezone.utc),
|
||||||
|
|
@ -418,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
licence_risk_count=licence_risk_count,
|
licence_risk_count=licence_risk_count,
|
||||||
open_capability_requests=open_cap_req_count,
|
open_capability_requests=open_cap_req_count,
|
||||||
ranked_suggestions=ranked_suggestions,
|
ranked_suggestions=ranked_suggestions,
|
||||||
open_workplans=open_workplan_rows,
|
open_workplans=relevant_rows,
|
||||||
|
residual_open_workplans=residual_rows,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -521,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||||
w for w in workstreams_all
|
w for w in workstreams_all
|
||||||
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||||
]
|
]
|
||||||
|
residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor))
|
||||||
open_ws_ids = [w.id for w in open_ws]
|
open_ws_ids = [w.id for w in open_ws]
|
||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
|
|
@ -580,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||||
),
|
),
|
||||||
archived=ws_counts.get("archived", 0),
|
archived=ws_counts.get("archived", 0),
|
||||||
total=sum(ws_counts.values()),
|
total=sum(ws_counts.values()),
|
||||||
|
residual_open=residual_open,
|
||||||
),
|
),
|
||||||
tasks=TaskTotals(
|
tasks=TaskTotals(
|
||||||
wait=task_totals_by_status.get("wait", 0),
|
wait=task_totals_by_status.get("wait", 0),
|
||||||
|
|
@ -668,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||||
id=w.id,
|
id=w.id,
|
||||||
title=w.title,
|
title=w.title,
|
||||||
status=normalize_workplan_status(w.status),
|
status=normalize_workplan_status(w.status),
|
||||||
|
flavor=w.flavor,
|
||||||
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
||||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||||
workplan_filename=workplan.get("filename"),
|
workplan_filename=workplan.get("filename"),
|
||||||
|
|
@ -795,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/deps", response_model=list[WorkstreamWithDeps])
|
@router.get("/deps", response_model=list[WorkstreamWithDeps])
|
||||||
async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
|
async def get_deps(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
include_residuals: bool = Query(False),
|
||||||
|
) -> list[WorkstreamWithDeps]:
|
||||||
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
|
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
|
||||||
|
|
||||||
Returns the same structure as open_workplans in /state/summary but skips
|
Returns the same structure as open_workplans in /state/summary but skips
|
||||||
|
|
@ -809,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
|
if not include_residuals:
|
||||||
|
open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)]
|
||||||
|
|
||||||
open_ws_ids = [w.id for w in open_ws]
|
open_ws_ids = [w.id for w in open_ws]
|
||||||
dep_rows = []
|
dep_rows = []
|
||||||
|
|
@ -944,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
||||||
if task.id in seen_task_ids:
|
if task.id in seen_task_ids:
|
||||||
continue
|
continue
|
||||||
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||||
|
if ws is not None and is_residual_flavor(ws.flavor):
|
||||||
|
continue
|
||||||
|
if is_residual_flavor(task.flavor):
|
||||||
|
continue
|
||||||
domain_slug = await _get_domain_slug_for_workplan(ws, session)
|
domain_slug = await _get_domain_slug_for_workplan(ws, session)
|
||||||
steps.append(NextStep(
|
steps.append(NextStep(
|
||||||
type="resolved_decision",
|
type="resolved_decision",
|
||||||
|
|
@ -988,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
||||||
Workplan.title,
|
Workplan.title,
|
||||||
Workplan.slug,
|
Workplan.slug,
|
||||||
Workplan.topic_id,
|
Workplan.topic_id,
|
||||||
|
Workplan.flavor,
|
||||||
).where(Workplan.id.in_(dep_ws_ids))
|
).where(Workplan.id.in_(dep_ws_ids))
|
||||||
)
|
)
|
||||||
ws_info = {
|
ws_info = {
|
||||||
|
|
@ -996,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
||||||
"title": title,
|
"title": title,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
|
"flavor": flavor,
|
||||||
}
|
}
|
||||||
for ws_id, status, title, slug, topic_id in ws_rows
|
for ws_id, status, title, slug, topic_id, flavor in ws_rows
|
||||||
}
|
}
|
||||||
|
|
||||||
ready_from_ws_ids = [
|
ready_from_ws_ids = [
|
||||||
|
|
@ -1023,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
||||||
|
|
||||||
for from_ws_id in ready_from_ws_ids:
|
for from_ws_id in ready_from_ws_ids:
|
||||||
from_ws = ws_info.get(from_ws_id, {})
|
from_ws = ws_info.get(from_ws_id, {})
|
||||||
|
if is_residual_flavor(from_ws.get("flavor")):
|
||||||
|
continue
|
||||||
todo_tasks = todo_by_ws.get(from_ws_id, [])
|
todo_tasks = todo_by_ws.get(from_ws_id, [])
|
||||||
if not todo_tasks:
|
if not todo_tasks:
|
||||||
continue
|
continue
|
||||||
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||||
if task.id in seen_task_ids:
|
if task.id in seen_task_ids:
|
||||||
continue
|
continue
|
||||||
|
if is_residual_flavor(task.flavor):
|
||||||
|
continue
|
||||||
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
|
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
|
||||||
_blocker_slugs = []
|
_blocker_slugs = []
|
||||||
for tid in dep_map[from_ws_id]:
|
for tid in dep_map[from_ws_id]:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, 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
|
||||||
|
|
@ -24,6 +24,11 @@ from api.schemas.workplan import (
|
||||||
)
|
)
|
||||||
from api.services.lifecycle import transition_workplan_status
|
from api.services.lifecycle import transition_workplan_status
|
||||||
from api.services.legacy_compat import retire_legacy_route
|
from api.services.legacy_compat import retire_legacy_route
|
||||||
|
from api.work_record_flavor import (
|
||||||
|
RESIDUAL_FLAVOR,
|
||||||
|
WORK_RECORD_FLAVORS,
|
||||||
|
normalize_flavor,
|
||||||
|
)
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
is_supported_workplan_status,
|
is_supported_workplan_status,
|
||||||
normalize_workplan_status,
|
normalize_workplan_status,
|
||||||
|
|
@ -88,6 +93,8 @@ async def _list_workplans(
|
||||||
status_filter: str | None,
|
status_filter: str | None,
|
||||||
owner: str | None,
|
owner: str | None,
|
||||||
slug: str | None,
|
slug: str | None,
|
||||||
|
flavor: str | None = None,
|
||||||
|
include_residuals: bool = True,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[Workplan]:
|
) -> list[Workplan]:
|
||||||
q = select(Workplan)
|
q = select(Workplan)
|
||||||
|
|
@ -106,6 +113,19 @@ async def _list_workplans(
|
||||||
q = q.where(Workplan.owner == owner)
|
q = q.where(Workplan.owner == owner)
|
||||||
if slug:
|
if slug:
|
||||||
q = q.where(Workplan.slug == slug)
|
q = q.where(Workplan.slug == slug)
|
||||||
|
wanted = normalize_flavor(flavor)
|
||||||
|
if wanted is not None:
|
||||||
|
if wanted not in WORK_RECORD_FLAVORS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Unknown work-record flavor {wanted!r}; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
q = q.where(Workplan.flavor == wanted)
|
||||||
|
elif not include_residuals:
|
||||||
|
q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR))
|
||||||
q = q.order_by(
|
q = q.order_by(
|
||||||
Workplan.planning_priority.asc().nullslast(),
|
Workplan.planning_priority.asc().nullslast(),
|
||||||
Workplan.planning_order.asc().nullslast(),
|
Workplan.planning_order.asc().nullslast(),
|
||||||
|
|
@ -365,6 +385,8 @@ async def list_workplans(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
|
flavor: str | None = Query(None),
|
||||||
|
include_residuals: bool = Query(True),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Workplan]:
|
) -> list[Workplan]:
|
||||||
return await _list_workplans(
|
return await _list_workplans(
|
||||||
|
|
@ -374,6 +396,8 @@ async def list_workplans(
|
||||||
status_filter=status,
|
status_filter=status,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
flavor=flavor,
|
||||||
|
include_residuals=include_residuals,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class WorkstreamTotals(BaseModel):
|
||||||
finished: int = 0
|
finished: int = 0
|
||||||
archived: int = 0
|
archived: int = 0
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
residual_open: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TaskTotals(BaseModel):
|
class TaskTotals(BaseModel):
|
||||||
|
|
@ -85,6 +86,7 @@ class StateSummary(BaseModel):
|
||||||
blocked_tasks: list[TaskRead] = []
|
blocked_tasks: list[TaskRead] = []
|
||||||
recent_progress: list[ProgressEventRead]
|
recent_progress: list[ProgressEventRead]
|
||||||
open_workplans: list[WorkstreamWithDeps] = []
|
open_workplans: list[WorkstreamWithDeps] = []
|
||||||
|
residual_open_workplans: list[WorkstreamWithDeps] = []
|
||||||
next_steps: list[NextStep] = []
|
next_steps: list[NextStep] = []
|
||||||
domains: list[DomainSummary] = []
|
domains: list[DomainSummary] = []
|
||||||
contribution_counts: dict[str, int] = {}
|
contribution_counts: dict[str, int] = {}
|
||||||
|
|
@ -98,6 +100,7 @@ class DashboardWorkplanRow(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
|
flavor: str | None = None
|
||||||
domain: str = "unknown"
|
domain: str = "unknown"
|
||||||
repo_label: str = "unassigned"
|
repo_label: str = "unassigned"
|
||||||
workplan_filename: str | None = None
|
workplan_filename: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from api.models.task import TaskPriority, TaskStatus
|
from api.models.task import TaskPriority, TaskStatus
|
||||||
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||||
from api.task_status import normalize_task_status
|
from api.task_status import normalize_task_status
|
||||||
|
from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusMixin(BaseModel):
|
class TaskStatusMixin(BaseModel):
|
||||||
|
|
@ -18,9 +19,23 @@ class TaskStatusMixin(BaseModel):
|
||||||
return normalize_task_status(value)
|
return normalize_task_status(value)
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
class TaskFlavorMixin(BaseModel):
|
||||||
|
@field_validator("flavor", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalize_flavor(cls, value):
|
||||||
|
flavor = normalize_flavor(value)
|
||||||
|
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown work-record flavor {flavor!r}; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
)
|
||||||
|
return flavor
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
|
||||||
id: uuid.UUID | None = None
|
id: uuid.UUID | None = None
|
||||||
record_id: str | None = None
|
record_id: str | None = None
|
||||||
|
flavor: str | None = None
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus = TaskStatus.todo
|
status: TaskStatus = TaskStatus.todo
|
||||||
|
|
@ -39,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(TaskStatusMixin):
|
class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
flavor: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus | None = None
|
status: TaskStatus | None = None
|
||||||
priority: TaskPriority | None = None
|
priority: TaskPriority | None = None
|
||||||
|
|
@ -102,6 +118,7 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
record_id: str | None = None
|
record_id: str | None = None
|
||||||
|
flavor: str | None = None
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ from typing import Literal
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
from api.schemas.workplan_dependency import WorkplanDepStub
|
from api.schemas.workplan_dependency import WorkplanDepStub
|
||||||
|
from api.work_record_flavor import (
|
||||||
|
FLAVOR_PROMOTION_REASONS,
|
||||||
|
WORK_RECORD_FLAVORS,
|
||||||
|
normalize_flavor,
|
||||||
|
normalize_promotion_reason,
|
||||||
|
)
|
||||||
from api.workplan_status import normalize_workplan_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
WorkplanStatus = Literal[
|
WorkplanStatus = Literal[
|
||||||
|
|
@ -28,7 +34,42 @@ class WorkplanStatusMixin(BaseModel):
|
||||||
return normalize_workplan_status(value)
|
return normalize_workplan_status(value)
|
||||||
|
|
||||||
|
|
||||||
class WorkplanCreate(WorkplanStatusMixin):
|
class WorkplanFlavorMixin(BaseModel):
|
||||||
|
@field_validator("flavor", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalise_flavor(cls, value):
|
||||||
|
flavor = normalize_flavor(value)
|
||||||
|
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown work-record flavor {flavor!r}; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
)
|
||||||
|
return flavor
|
||||||
|
|
||||||
|
@field_validator("flavor_promotion_reason", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalise_promotion_reason(cls, value):
|
||||||
|
reason = normalize_promotion_reason(value)
|
||||||
|
if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown flavor_promotion_reason {reason!r}; "
|
||||||
|
f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
|
||||||
|
)
|
||||||
|
return reason
|
||||||
|
|
||||||
|
@field_validator("flavor_promoted_from", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalise_promoted_from(cls, value):
|
||||||
|
flavor = normalize_flavor(value)
|
||||||
|
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown flavor_promoted_from {flavor!r}; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
)
|
||||||
|
return flavor
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
|
||||||
id: uuid.UUID | None = None
|
id: uuid.UUID | None = None
|
||||||
repo_id: uuid.UUID
|
repo_id: uuid.UUID
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
|
|
@ -40,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin):
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
planning_priority: str | None = None
|
planning_priority: str | None = None
|
||||||
planning_order: int | None = None
|
planning_order: int | None = None
|
||||||
|
flavor: str | None = None
|
||||||
|
flavor_promotion_reason: str | None = None
|
||||||
|
flavor_promoted_from: str | None = None
|
||||||
execution_state: ExecutionState = "manual"
|
execution_state: ExecutionState = "manual"
|
||||||
launch_mode: LaunchMode = "manual"
|
launch_mode: LaunchMode = "manual"
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
concurrency_mode: ConcurrencyMode = "sequential"
|
||||||
|
|
@ -49,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin):
|
||||||
repo_goal_id: uuid.UUID | None = None
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkplanUpdate(WorkplanStatusMixin):
|
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: WorkplanStatus | None = None
|
status: WorkplanStatus | None = None
|
||||||
|
|
@ -57,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin):
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
planning_priority: str | None = None
|
planning_priority: str | None = None
|
||||||
planning_order: int | None = None
|
planning_order: int | None = None
|
||||||
|
flavor: str | None = None
|
||||||
|
flavor_promotion_reason: str | None = None
|
||||||
|
flavor_promoted_from: str | None = None
|
||||||
execution_state: ExecutionState | None = None
|
execution_state: ExecutionState | None = None
|
||||||
launch_mode: LaunchMode | None = None
|
launch_mode: LaunchMode | None = None
|
||||||
concurrency_mode: ConcurrencyMode | None = None
|
concurrency_mode: ConcurrencyMode | None = None
|
||||||
|
|
@ -95,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin):
|
||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
planning_priority: str | None = None
|
planning_priority: str | None = None
|
||||||
planning_order: int | None = None
|
planning_order: int | None = None
|
||||||
|
flavor: str | None = None
|
||||||
|
flavor_promotion_reason: str | None = None
|
||||||
|
flavor_promoted_from: str | None = None
|
||||||
execution_state: ExecutionState = "manual"
|
execution_state: ExecutionState = "manual"
|
||||||
launch_mode: LaunchMode = "manual"
|
launch_mode: LaunchMode = "manual"
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
concurrency_mode: ConcurrencyMode = "sequential"
|
||||||
|
|
|
||||||
48
api/work_record_flavor.py
Normal file
48
api/work_record_flavor.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Work-record flavor (STATE-WP-0092).
|
||||||
|
|
||||||
|
Flavor is a closed bucket on workplans and tasks, orthogonal to kind and
|
||||||
|
status. Unset flavor is not residual.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
WORK_RECORD_FLAVORS: tuple[str, ...] = (
|
||||||
|
"planning",
|
||||||
|
"implementation",
|
||||||
|
"refactoring",
|
||||||
|
"extension",
|
||||||
|
"residual",
|
||||||
|
)
|
||||||
|
RESIDUAL_FLAVOR = "residual"
|
||||||
|
FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk")
|
||||||
|
|
||||||
|
_EMPTY = {"", "~", "null", "none", "nil"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_flavor(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in _EMPTY:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_flavor(value: Any) -> bool:
|
||||||
|
flavor = normalize_flavor(value)
|
||||||
|
return flavor is None or flavor in WORK_RECORD_FLAVORS
|
||||||
|
|
||||||
|
|
||||||
|
def is_residual_flavor(value: Any) -> bool:
|
||||||
|
return normalize_flavor(value) == RESIDUAL_FLAVOR
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_promotion_reason(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in _EMPTY:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
@ -234,11 +234,13 @@ const _filtersForm = Inputs.form(
|
||||||
domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}),
|
domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}),
|
||||||
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
|
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
|
||||||
owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}),
|
owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}),
|
||||||
|
include_residuals: Inputs.toggle({label: "Include residuals", value: false}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
template: ({domain, status, owner}) => html`<div class="filter-bar">
|
template: ({domain, status, owner, include_residuals}) => html`<div class="filter-bar">
|
||||||
${domain}${status}
|
${domain}${status}
|
||||||
<div class="filter-text-input">${owner}</div>
|
<div class="filter-text-input">${owner}</div>
|
||||||
|
${include_residuals}
|
||||||
</div>`,
|
</div>`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -253,7 +255,8 @@ const filters = Generators.input(_filtersForm);
|
||||||
const filtered = data.filter(w =>
|
const filtered = data.filter(w =>
|
||||||
(filters.domain.length === 0 || filters.domain.includes(w.domain)) &&
|
(filters.domain.length === 0 || filters.domain.includes(w.domain)) &&
|
||||||
(filters.status.length === 0 || filters.status.includes(w.status)) &&
|
(filters.status.length === 0 || filters.status.includes(w.status)) &&
|
||||||
(!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase()))
|
(!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) &&
|
||||||
|
(filters.include_residuals || w.flavor !== "residual")
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
57
docs/work-record-flavor.md
Normal file
57
docs/work-record-flavor.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Work-record flavor and residual default-exclusion
|
||||||
|
|
||||||
|
Status: active (STATE-WP-0092)
|
||||||
|
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Flavor
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Workplans and tasks carry an optional **flavor** bucket:
|
||||||
|
|
||||||
|
`planning` | `implementation` | `refactoring` | `extension` | `residual`
|
||||||
|
|
||||||
|
Flavor is not a kind and not a status. Unset flavor is **not** residual.
|
||||||
|
|
||||||
|
`flavor: residual` means leftover work that is **not** as valuable as the
|
||||||
|
main functional and non-functional requirements. Default State Hub views of
|
||||||
|
open work **omit** residuals. Agents must not pick residual workplans or
|
||||||
|
residual tasks for implementation unless the flavor has been promoted.
|
||||||
|
|
||||||
|
Promotion is a file edit:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
flavor: implementation # or planning | refactoring | extension
|
||||||
|
flavor_promoted_from: residual
|
||||||
|
flavor_promotion_reason: demand # or risk
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `statehub fix-consistency`. After promotion the record re-enters the
|
||||||
|
default view.
|
||||||
|
|
||||||
|
`origin: residual` on intakes remains provenance. It does not hide a
|
||||||
|
workplan; `flavor` does.
|
||||||
|
|
||||||
|
## `depends_on`
|
||||||
|
|
||||||
|
Workplan frontmatter `depends_on` is the canonical list of blocker
|
||||||
|
**workplan ids**. C-20 indexes it (legacy alias: `depends_on_workplans`).
|
||||||
|
Do not use `depends_on_workplans` on new files.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Surface | Default | Residual access |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /workplans/` | includes residuals (full index) | `?flavor=residual` or `?include_residuals=false` to hide |
|
||||||
|
| `GET /state/summary` `open_workplans` | omits residuals | `?include_residuals=true` fills `residual_open_workplans`; `totals.workstreams.residual_open` is always the count |
|
||||||
|
| `GET /state/next_steps` | never recommends residual work | — |
|
||||||
|
| `GET /state/deps` | omits residuals | `?include_residuals=true` |
|
||||||
|
|
||||||
|
Unknown flavor values fail closed on write (HTTP 422). Consistency check
|
||||||
|
C-36 warns; it does not invent a flavor for historic files.
|
||||||
|
|
||||||
|
## Fleet follow-on
|
||||||
|
|
||||||
|
- Backfill existing files: `CUST-WP-0072`
|
||||||
|
- Fabric graph: `RAIL-FAB-WP-0030`
|
||||||
|
- Cross-owner waits: `COORDINATION-WP-0005`
|
||||||
|
|
||||||
|
Do not message `ops-warden` for secrets; see credential routing.
|
||||||
|
|
@ -17,8 +17,15 @@ repo: state-hub
|
||||||
status: proposed
|
status: proposed
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
|
flavor: planning
|
||||||
|
depends_on: []
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`flavor` is `planning` | `implementation` | `refactoring` | `extension` |
|
||||||
|
`residual`. Unset is not residual. Default State Hub open views omit
|
||||||
|
`flavor: residual` until demand or risk promotes it (STATE-WP-0092).
|
||||||
|
`depends_on` lists blocker workplan ids; C-20 indexes that field.
|
||||||
|
|
||||||
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
|
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
|
||||||
their existing `state_hub_workstream_id` values. Write files first, then run
|
their existing `state_hub_workstream_id` values. Write files first, then run
|
||||||
State Hub consistency sync after this repo is registered.
|
State Hub consistency sync after this repo is registered.
|
||||||
|
|
|
||||||
39
migrations/versions/c6f7a8b9d0e1_work_record_flavor.py
Normal file
39
migrations/versions/c6f7a8b9d0e1_work_record_flavor.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""work-record flavor on workplans and tasks (STATE-WP-0092)
|
||||||
|
|
||||||
|
Flavor is a closed bucket (planning, implementation, refactoring, extension,
|
||||||
|
residual), orthogonal to kind and status. Nullable: unset is not residual.
|
||||||
|
|
||||||
|
Revision ID: c6f7a8b9d0e1
|
||||||
|
Revises: b5e6f7a8c9d0
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "c6f7a8b9d0e1"
|
||||||
|
down_revision = "b5e6f7a8c9d0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("workplans", sa.Column("flavor", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"workplans",
|
||||||
|
sa.Column("flavor_promotion_reason", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"workplans",
|
||||||
|
sa.Column("flavor_promoted_from", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workplans_flavor", "workplans", ["flavor"])
|
||||||
|
op.add_column("tasks", sa.Column("flavor", sa.String(length=32), nullable=True))
|
||||||
|
op.create_index("ix_tasks_flavor", "tasks", ["flavor"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_tasks_flavor", table_name="tasks")
|
||||||
|
op.drop_column("tasks", "flavor")
|
||||||
|
op.drop_index("ix_workplans_flavor", table_name="workplans")
|
||||||
|
op.drop_column("workplans", "flavor_promoted_from")
|
||||||
|
op.drop_column("workplans", "flavor_promotion_reason")
|
||||||
|
op.drop_column("workplans", "flavor")
|
||||||
|
|
@ -39,6 +39,9 @@ Checks:
|
||||||
C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04)
|
C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04)
|
||||||
C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft)
|
C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft)
|
||||||
C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings
|
C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings
|
||||||
|
C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list
|
||||||
|
C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref
|
||||||
|
C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins)
|
||||||
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
|
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
@ -118,6 +121,13 @@ from api.task_status import ( # noqa: E402
|
||||||
TERMINAL_TASK_STATUSES,
|
TERMINAL_TASK_STATUSES,
|
||||||
normalize_task_status,
|
normalize_task_status,
|
||||||
)
|
)
|
||||||
|
from api.work_record_flavor import ( # noqa: E402
|
||||||
|
FLAVOR_PROMOTION_REASONS,
|
||||||
|
WORK_RECORD_FLAVORS,
|
||||||
|
is_residual_flavor,
|
||||||
|
normalize_flavor,
|
||||||
|
normalize_promotion_reason,
|
||||||
|
)
|
||||||
|
|
||||||
_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
|
_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
|
||||||
|
|
||||||
|
|
@ -509,6 +519,36 @@ def _as_list(value: Any) -> list[str]:
|
||||||
return [str(value).strip().strip('"')]
|
return [str(value).strip().strip('"')]
|
||||||
|
|
||||||
|
|
||||||
|
_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_preserve(items: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _frontmatter_depends_on_workplans(meta: dict) -> list[str]:
|
||||||
|
"""Canonical `depends_on` plus legacy `depends_on_workplans` alias."""
|
||||||
|
combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans"))
|
||||||
|
return _dedupe_preserve(
|
||||||
|
[item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _frontmatter_depends_on_tasks(meta: dict) -> list[str]:
|
||||||
|
from_alias = _as_list(meta.get("depends_on_tasks"))
|
||||||
|
from_depends_on = [
|
||||||
|
item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item)
|
||||||
|
]
|
||||||
|
return _dedupe_preserve(from_alias + from_depends_on)
|
||||||
|
|
||||||
|
|
||||||
def _as_int_or_none(value: Any) -> int | None:
|
def _as_int_or_none(value: Any) -> int | None:
|
||||||
if value in (None, "", "~", "null", "None", "none"):
|
if value in (None, "", "~", "null", "None", "none"):
|
||||||
return None
|
return None
|
||||||
|
|
@ -1664,6 +1704,86 @@ def check_repo(
|
||||||
_fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order},
|
_fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
file_flavor = normalize_flavor(meta.get("flavor"))
|
||||||
|
if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-36",
|
||||||
|
message=(
|
||||||
|
f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; "
|
||||||
|
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=file_flavor,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
file_flavor = None
|
||||||
|
db_flavor = normalize_flavor(ws.get("flavor"))
|
||||||
|
if file_flavor != db_flavor:
|
||||||
|
report.add(
|
||||||
|
severity="WARN", check_id="C-19",
|
||||||
|
message=(
|
||||||
|
f"Flavor drift in '{ws.get('slug')}': "
|
||||||
|
f"file={file_flavor!r} db={db_flavor!r} (file wins)"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=file_flavor,
|
||||||
|
db_value=db_flavor,
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor},
|
||||||
|
)
|
||||||
|
file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason"))
|
||||||
|
if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-36",
|
||||||
|
message=(
|
||||||
|
f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; "
|
||||||
|
f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=file_reason,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
file_reason = None
|
||||||
|
db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason"))
|
||||||
|
if file_reason != db_reason:
|
||||||
|
report.add(
|
||||||
|
severity="WARN", check_id="C-19",
|
||||||
|
message=(
|
||||||
|
f"Flavor promotion reason drift in '{ws.get('slug')}': "
|
||||||
|
f"file={file_reason!r} db={db_reason!r} (file wins)"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
file_value=file_reason,
|
||||||
|
db_value=db_reason,
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={
|
||||||
|
"ws_id": ws_id,
|
||||||
|
"field": "flavor_promotion_reason",
|
||||||
|
"value": file_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if is_residual_flavor(file_flavor):
|
||||||
|
origin = str(meta.get("origin") or "").strip()
|
||||||
|
origin_ref = str(meta.get("origin_ref") or "").strip()
|
||||||
|
if not origin and not origin_ref:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-37",
|
||||||
|
message=(
|
||||||
|
f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref "
|
||||||
|
"provenance (not invented)"
|
||||||
|
),
|
||||||
|
file_path=fname,
|
||||||
|
db_id=ws_id,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
|
||||||
# C-10, C-11, C-12: task-level checks
|
# C-10, C-11, C-12: task-level checks
|
||||||
db_task_by_id: dict[str, dict] = {}
|
db_task_by_id: dict[str, dict] = {}
|
||||||
if isinstance(db_tasks, list):
|
if isinstance(db_tasks, list):
|
||||||
|
|
@ -1688,7 +1808,7 @@ def check_repo(
|
||||||
if dep.get("to_task_id"):
|
if dep.get("to_task_id"):
|
||||||
existing_dep_keys.add(("task", dep["to_task_id"], rel))
|
existing_dep_keys.add(("task", dep["to_task_id"], rel))
|
||||||
|
|
||||||
for target_wp_id in _as_list(meta.get("depends_on_workplans")):
|
for target_wp_id in _frontmatter_depends_on_workplans(meta):
|
||||||
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
|
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
|
||||||
if not target_ws_id:
|
if not target_ws_id:
|
||||||
report.add(
|
report.add(
|
||||||
|
|
@ -1717,7 +1837,7 @@ def check_repo(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
for target_task_id in _as_list(meta.get("depends_on_tasks")):
|
for target_task_id in _frontmatter_depends_on_tasks(meta):
|
||||||
target_sh_id = task_file_id_to_sh_id.get(target_task_id)
|
target_sh_id = task_file_id_to_sh_id.get(target_task_id)
|
||||||
if not target_sh_id:
|
if not target_sh_id:
|
||||||
report.add(
|
report.add(
|
||||||
|
|
@ -1849,6 +1969,36 @@ def check_repo(
|
||||||
fixable=True,
|
fixable=True,
|
||||||
_fix_context={"task_id": t_sh_id, "description": file_description},
|
_fix_context={"task_id": t_sh_id, "description": file_description},
|
||||||
)
|
)
|
||||||
|
file_task_flavor = normalize_flavor(task.get("flavor"))
|
||||||
|
if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-36",
|
||||||
|
message=(
|
||||||
|
f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'"
|
||||||
|
),
|
||||||
|
file_path=f"{fname}#{t_id}",
|
||||||
|
db_id=t_sh_id,
|
||||||
|
file_value=file_task_flavor,
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
file_task_flavor = None
|
||||||
|
db_task_flavor = normalize_flavor(db_task.get("flavor"))
|
||||||
|
if file_task_flavor != db_task_flavor:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-38",
|
||||||
|
message=(
|
||||||
|
f"Task flavor drift '{t_id}': "
|
||||||
|
f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)"
|
||||||
|
),
|
||||||
|
file_path=f"{fname}#{t_id}",
|
||||||
|
db_id=t_sh_id,
|
||||||
|
file_value=file_task_flavor,
|
||||||
|
db_value=db_task_flavor,
|
||||||
|
fixable=True,
|
||||||
|
_fix_context={"task_id": t_sh_id, "flavor": file_task_flavor},
|
||||||
|
)
|
||||||
elif t_id:
|
elif t_id:
|
||||||
# C-11: task exists in file but not linked to DB
|
# C-11: task exists in file but not linked to DB
|
||||||
ws_status = ws.get("status", "")
|
ws_status = ws.get("status", "")
|
||||||
|
|
@ -3062,6 +3212,15 @@ def fix_repo(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
create_flavor = normalize_flavor(meta.get("flavor"))
|
||||||
|
if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
create_flavor = None
|
||||||
|
create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason"))
|
||||||
|
if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS:
|
||||||
|
create_reason = None
|
||||||
|
create_from = normalize_flavor(meta.get("flavor_promoted_from"))
|
||||||
|
if create_from is not None and create_from not in WORK_RECORD_FLAVORS:
|
||||||
|
create_from = None
|
||||||
ws_data = _api_post(api_base, "/workplans", {
|
ws_data = _api_post(api_base, "/workplans", {
|
||||||
"id": desired_ws_id,
|
"id": desired_ws_id,
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
|
|
@ -3072,6 +3231,9 @@ def fix_repo(
|
||||||
"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_priority": str(meta.get("planning_priority", "")).strip() or None,
|
||||||
"planning_order": _as_int_or_none(meta.get("planning_order")),
|
"planning_order": _as_int_or_none(meta.get("planning_order")),
|
||||||
|
"flavor": create_flavor,
|
||||||
|
"flavor_promotion_reason": create_reason,
|
||||||
|
"flavor_promoted_from": create_from,
|
||||||
})
|
})
|
||||||
if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data):
|
if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data):
|
||||||
last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response"
|
last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response"
|
||||||
|
|
@ -3116,6 +3278,9 @@ def fix_repo(
|
||||||
if raw_task_id not in (None, "", "~", "null", "None", "none")
|
if raw_task_id not in (None, "", "~", "null", "None", "none")
|
||||||
else _derived_work_record_uuid(t_id)
|
else _derived_work_record_uuid(t_id)
|
||||||
)
|
)
|
||||||
|
task_flavor = normalize_flavor(task.get("flavor"))
|
||||||
|
if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS:
|
||||||
|
task_flavor = None
|
||||||
t_data = _api_post(api_base, "/tasks", {
|
t_data = _api_post(api_base, "/tasks", {
|
||||||
"id": desired_task_id,
|
"id": desired_task_id,
|
||||||
"workplan_id": new_ws_id,
|
"workplan_id": new_ws_id,
|
||||||
|
|
@ -3124,6 +3289,7 @@ def fix_repo(
|
||||||
"status": t_status,
|
"status": t_status,
|
||||||
"priority": t_priority,
|
"priority": t_priority,
|
||||||
"assignee": task.get("assignee") or None,
|
"assignee": task.get("assignee") or None,
|
||||||
|
"flavor": task_flavor,
|
||||||
})
|
})
|
||||||
if t_data and "_error" not in t_data:
|
if t_data and "_error" not in t_data:
|
||||||
t_db_id = t_data["id"]
|
t_db_id = t_data["id"]
|
||||||
|
|
@ -3349,6 +3515,19 @@ def fix_repo(
|
||||||
f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}"
|
f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif issue.check_id == "C-38":
|
||||||
|
task_id = ctx["task_id"]
|
||||||
|
flavor = ctx.get("flavor")
|
||||||
|
result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor})
|
||||||
|
if result is not None and "_error" not in result:
|
||||||
|
report.fixes_applied.append(
|
||||||
|
f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}"
|
||||||
|
)
|
||||||
|
elif result is not None:
|
||||||
|
report.fixes_applied.append(
|
||||||
|
f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}"
|
||||||
|
)
|
||||||
|
|
||||||
elif issue.check_id == "C-15":
|
elif issue.check_id == "C-15":
|
||||||
# T03 — writeback: DB is ahead of file — patch file to match DB.
|
# T03 — writeback: DB is ahead of file — patch file to match DB.
|
||||||
if no_writeback:
|
if no_writeback:
|
||||||
|
|
|
||||||
|
|
@ -1328,6 +1328,173 @@ class TestC20DependencyDetection:
|
||||||
|
|
||||||
assert "C-20" not in [issue.check_id for issue in report.issues]
|
assert "C-20" not in [issue.check_id for issue in report.issues]
|
||||||
|
|
||||||
|
def test_depends_on_alias_satisfies_workplan_dependency(self, tmp_path, monkeypatch):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
workplans = repo / "workplans"
|
||||||
|
workplans.mkdir(parents=True)
|
||||||
|
(workplans / "STATE-WP-0001-base.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"id: STATE-WP-0001\n"
|
||||||
|
"title: Base\n"
|
||||||
|
"domain: financials\n"
|
||||||
|
"repo: demo-repo\n"
|
||||||
|
"status: active\n"
|
||||||
|
"state_hub_workstream_id: \"base-ws\"\n"
|
||||||
|
"---\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workplans / "STATE-WP-0002-dependent.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"id: STATE-WP-0002\n"
|
||||||
|
"title: Dependent\n"
|
||||||
|
"domain: financials\n"
|
||||||
|
"repo: demo-repo\n"
|
||||||
|
"status: active\n"
|
||||||
|
"state_hub_workstream_id: \"dependent-ws\"\n"
|
||||||
|
"depends_on:\n"
|
||||||
|
" - STATE-WP-0001\n"
|
||||||
|
"---\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get(_api_base, path, params=None, **_kwargs):
|
||||||
|
if path == "/repos/demo-repo":
|
||||||
|
import socket
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "repo-1",
|
||||||
|
"slug": "demo-repo",
|
||||||
|
"local_path": str(repo),
|
||||||
|
"host_paths": {socket.gethostname(): str(repo)},
|
||||||
|
"domain_slug": "financials",
|
||||||
|
}
|
||||||
|
if path == "/workplans/base-ws":
|
||||||
|
return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"}
|
||||||
|
if path == "/workplans/dependent-ws":
|
||||||
|
return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"}
|
||||||
|
if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}:
|
||||||
|
return []
|
||||||
|
if path == "/workplans/base-ws/dependencies":
|
||||||
|
return []
|
||||||
|
if path == "/workplans/dependent-ws/dependencies":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "dep-1",
|
||||||
|
"from_workplan_id": "dependent-ws",
|
||||||
|
"to_workplan_id": "base-ws",
|
||||||
|
"to_task_id": None,
|
||||||
|
"relationship_type": "blocks",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if path == "/workplans" and params == {"repo_id": "repo-1"}:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||||
|
|
||||||
|
report = check_repo("http://unused", "demo-repo")
|
||||||
|
|
||||||
|
assert "C-20" not in [issue.check_id for issue in report.issues]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlavorChecks:
|
||||||
|
def test_unknown_flavor_warns_c36(self, tmp_path, monkeypatch):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
workplans = repo / "workplans"
|
||||||
|
workplans.mkdir(parents=True)
|
||||||
|
(workplans / "STATE-WP-0001-odd.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"id: STATE-WP-0001\n"
|
||||||
|
"title: Odd\n"
|
||||||
|
"domain: financials\n"
|
||||||
|
"repo: demo-repo\n"
|
||||||
|
"status: active\n"
|
||||||
|
"flavor: sidetrack\n"
|
||||||
|
"state_hub_workstream_id: \"odd-ws\"\n"
|
||||||
|
"---\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get(_api_base, path, params=None, **_kwargs):
|
||||||
|
if path == "/repos/demo-repo":
|
||||||
|
import socket
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "repo-1",
|
||||||
|
"slug": "demo-repo",
|
||||||
|
"local_path": str(repo),
|
||||||
|
"host_paths": {socket.gethostname(): str(repo)},
|
||||||
|
"domain_slug": "financials",
|
||||||
|
}
|
||||||
|
if path == "/workplans/odd-ws":
|
||||||
|
return {
|
||||||
|
"id": "odd-ws",
|
||||||
|
"repo_id": "repo-1",
|
||||||
|
"slug": "state-wp-0001",
|
||||||
|
"title": "Odd",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
if path == "/tasks":
|
||||||
|
return []
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
return []
|
||||||
|
if path == "/workplans":
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||||
|
report = check_repo("http://unused", "demo-repo")
|
||||||
|
assert "C-36" in [issue.check_id for issue in report.issues]
|
||||||
|
|
||||||
|
def test_residual_without_origin_warns_c37(self, tmp_path, monkeypatch):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
workplans = repo / "workplans"
|
||||||
|
workplans.mkdir(parents=True)
|
||||||
|
(workplans / "STATE-WP-0001-left.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"id: STATE-WP-0001\n"
|
||||||
|
"title: Left\n"
|
||||||
|
"domain: financials\n"
|
||||||
|
"repo: demo-repo\n"
|
||||||
|
"status: active\n"
|
||||||
|
"flavor: residual\n"
|
||||||
|
"state_hub_workstream_id: \"left-ws\"\n"
|
||||||
|
"---\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get(_api_base, path, params=None, **_kwargs):
|
||||||
|
if path == "/repos/demo-repo":
|
||||||
|
import socket
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "repo-1",
|
||||||
|
"slug": "demo-repo",
|
||||||
|
"local_path": str(repo),
|
||||||
|
"host_paths": {socket.gethostname(): str(repo)},
|
||||||
|
"domain_slug": "financials",
|
||||||
|
}
|
||||||
|
if path == "/workplans/left-ws":
|
||||||
|
return {
|
||||||
|
"id": "left-ws",
|
||||||
|
"repo_id": "repo-1",
|
||||||
|
"slug": "state-wp-0001",
|
||||||
|
"title": "Left",
|
||||||
|
"status": "active",
|
||||||
|
"flavor": "residual",
|
||||||
|
}
|
||||||
|
if path == "/tasks":
|
||||||
|
return []
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
return []
|
||||||
|
if path == "/workplans":
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("consistency_check._api_get", fake_get)
|
||||||
|
report = check_repo("http://unused", "demo-repo")
|
||||||
|
assert "C-37" in [issue.check_id for issue in report.issues]
|
||||||
|
|
||||||
|
|
||||||
class TestC06WorkstreamCreation:
|
class TestC06WorkstreamCreation:
|
||||||
def test_fix_repo_bootstraps_legacy_ids_only_into_empty_projection(
|
def test_fix_repo_bootstraps_legacy_ids_only_into_empty_projection(
|
||||||
|
|
|
||||||
136
tests/test_work_record_flavor.py
Normal file
136
tests/test_work_record_flavor.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""STATE-WP-0092: flavor indexing, residual default-exclusion, depends_on alias."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.test_routers_core import (
|
||||||
|
_create_domain,
|
||||||
|
_create_repo,
|
||||||
|
_create_task,
|
||||||
|
_create_topic,
|
||||||
|
_create_workstream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_flavor_rejected_on_create(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
repo = await _create_repo(client)
|
||||||
|
r = await client.post(
|
||||||
|
"/workplans/",
|
||||||
|
json={
|
||||||
|
"repo_id": repo["id"],
|
||||||
|
"topic_id": topic["id"],
|
||||||
|
"slug": "bad-flavor",
|
||||||
|
"title": "Bad flavor",
|
||||||
|
"flavor": "sidetrack",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flavor_round_trip_and_list_filters(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
relevant = await _create_workstream(
|
||||||
|
client, topic["id"], slug="rel-wp", title="Relevant", flavor="implementation"
|
||||||
|
)
|
||||||
|
residual = await _create_workstream(
|
||||||
|
client, topic["id"], slug="res-wp", title="Leftover", flavor="residual"
|
||||||
|
)
|
||||||
|
unspecified = await _create_workstream(
|
||||||
|
client, topic["id"], slug="unset-wp", title="Historic"
|
||||||
|
)
|
||||||
|
|
||||||
|
listed = await client.get("/workplans/")
|
||||||
|
ids = {row["id"] for row in listed.json()}
|
||||||
|
assert relevant["id"] in ids
|
||||||
|
assert residual["id"] in ids
|
||||||
|
assert unspecified["id"] in ids
|
||||||
|
|
||||||
|
hidden = await client.get("/workplans/", params={"include_residuals": "false"})
|
||||||
|
hidden_ids = {row["id"] for row in hidden.json()}
|
||||||
|
assert relevant["id"] in hidden_ids
|
||||||
|
assert unspecified["id"] in hidden_ids
|
||||||
|
assert residual["id"] not in hidden_ids
|
||||||
|
|
||||||
|
only_residual = await client.get("/workplans/", params={"flavor": "residual"})
|
||||||
|
only_ids = {row["id"] for row in only_residual.json()}
|
||||||
|
assert only_ids == {residual["id"]}
|
||||||
|
|
||||||
|
got = await client.get(f"/workplans/{residual['id']}")
|
||||||
|
assert got.json()["flavor"] == "residual"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_omits_residuals_unless_included(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
relevant = await _create_workstream(
|
||||||
|
client, topic["id"], slug="sum-rel", title="Do this", flavor="planning"
|
||||||
|
)
|
||||||
|
residual = await _create_workstream(
|
||||||
|
client, topic["id"], slug="sum-res", title="Later maybe", flavor="residual"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = (await client.get("/state/summary")).json()
|
||||||
|
open_ids = {row["id"] for row in summary["open_workplans"]}
|
||||||
|
assert relevant["id"] in open_ids
|
||||||
|
assert residual["id"] not in open_ids
|
||||||
|
assert summary["totals"]["workstreams"]["residual_open"] == 1
|
||||||
|
assert summary["residual_open_workplans"] == []
|
||||||
|
|
||||||
|
included = (await client.get("/state/summary", params={"include_residuals": "true"})).json()
|
||||||
|
residual_ids = {row["id"] for row in included["residual_open_workplans"]}
|
||||||
|
assert residual["id"] in residual_ids
|
||||||
|
assert relevant["id"] not in residual_ids
|
||||||
|
open_ids_still = {row["id"] for row in included["open_workplans"]}
|
||||||
|
assert residual["id"] not in open_ids_still
|
||||||
|
|
||||||
|
deps = (await client.get("/state/deps")).json()
|
||||||
|
dep_ids = {row["id"] for row in deps}
|
||||||
|
assert relevant["id"] in dep_ids
|
||||||
|
assert residual["id"] not in dep_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_next_steps_skips_residual_workplan(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
blocker = await _create_workstream(
|
||||||
|
client, topic["id"], slug="done-dep", title="Finished dep", status="finished"
|
||||||
|
)
|
||||||
|
residual = await _create_workstream(
|
||||||
|
client, topic["id"], slug="res-ready", title="Residual leftover", flavor="residual"
|
||||||
|
)
|
||||||
|
r = await client.post(
|
||||||
|
f"/workplans/{residual['id']}/dependencies/",
|
||||||
|
json={"to_workstream_id": blocker["id"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
await _create_task(client, residual["id"], title="Do not start this")
|
||||||
|
|
||||||
|
steps = (await client.get("/state/next_steps")).json()
|
||||||
|
assert all(step.get("workplan_id") != residual["id"] for step in steps)
|
||||||
|
assert all("Residual leftover" not in (step.get("message") or "") for step in steps)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_flavor_round_trip(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
ws = await _create_workstream(client, topic["id"], slug="task-flavor-wp")
|
||||||
|
r = await client.post(
|
||||||
|
"/tasks/",
|
||||||
|
json={"workplan_id": ws["id"], "title": "Leftover task", "flavor": "residual"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["flavor"] == "residual"
|
||||||
|
patched = await client.patch(
|
||||||
|
f"/tasks/{r.json()['id']}",
|
||||||
|
json={"flavor": "implementation", "suppress_token_event": True},
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["flavor"] == "implementation"
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Workplan flavor, depends_on indexing, and residual default-exclusion"
|
title: "Workplan flavor, depends_on indexing, and residual default-exclusion"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: proposed
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
flavor: planning
|
flavor: planning
|
||||||
|
|
@ -62,7 +62,7 @@ parsing unknown frontmatter keys.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0092-T01
|
id: STATE-WP-0092-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c5c967ec-6f4c-541e-957d-7ae9489fbf6d"
|
state_hub_task_id: "c5c967ec-6f4c-541e-957d-7ae9489fbf6d"
|
||||||
```
|
```
|
||||||
|
|
@ -88,7 +88,7 @@ live leftover is equal open demand.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0092-T02
|
id: STATE-WP-0092-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [STATE-WP-0092-T01]
|
depends_on: [STATE-WP-0092-T01]
|
||||||
state_hub_task_id: "63d9c134-0b2b-592b-a7df-a58eaa3fdc99"
|
state_hub_task_id: "63d9c134-0b2b-592b-a7df-a58eaa3fdc99"
|
||||||
|
|
@ -110,7 +110,7 @@ and C-20 no longer requires the unused `depends_on_workplans` spelling.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0092-T03
|
id: STATE-WP-0092-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [STATE-WP-0092-T02]
|
depends_on: [STATE-WP-0092-T02]
|
||||||
state_hub_task_id: "0d53713b-d6f0-597d-8f87-9f86e5ca2235"
|
state_hub_task_id: "0d53713b-d6f0-597d-8f87-9f86e5ca2235"
|
||||||
|
|
@ -136,7 +136,7 @@ it.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0092-T04
|
id: STATE-WP-0092-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [STATE-WP-0092-T03]
|
depends_on: [STATE-WP-0092-T03]
|
||||||
state_hub_task_id: "764a4b17-5119-572a-8aa1-581d719f07fa"
|
state_hub_task_id: "764a4b17-5119-572a-8aa1-581d719f07fa"
|
||||||
|
|
@ -156,7 +156,7 @@ agent snippet exists for later instruction rollout.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0092-T05
|
id: STATE-WP-0092-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [STATE-WP-0092-T02, STATE-WP-0092-T03]
|
depends_on: [STATE-WP-0092-T02, STATE-WP-0092-T03]
|
||||||
state_hub_task_id: "ab74d06b-acc4-52da-ac20-ac564ca8a7ed"
|
state_hub_task_id: "ab74d06b-acc4-52da-ac20-ac564ca8a7ed"
|
||||||
|
|
@ -171,3 +171,9 @@ flavor — that is `CUST-WP-0072`.
|
||||||
|
|
||||||
Done when the new tests are green and `fix-consistency` on this repo
|
Done when the new tests are green and `fix-consistency` on this repo
|
||||||
does not warn on this file’s advisory `flavor:` / `depends_on` keys.
|
does not warn on this file’s advisory `flavor:` / `depends_on` keys.
|
||||||
|
|
||||||
|
2026-09-14: T01–T05 landed in repo. Canon v0.2 flavor list is machine-checkable.
|
||||||
|
Hub indexes `flavor` / `depends_on`, default summary/next_steps/deps omit
|
||||||
|
`flavor: residual`, and C-20 accepts `depends_on`. Live primary views change
|
||||||
|
after alembic `c6f7a8b9d0e1` is applied and this revision is deployed. Until
|
||||||
|
then CUST-WP-0072 should wait.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue