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
|
||||
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" \
|
||||
| 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
|
||||
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/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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class Task(Base, TimestampMixin):
|
|||
# title, so renaming a heading looks like one task vanishing and another
|
||||
# appearing.
|
||||
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)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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)
|
||||
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)
|
||||
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(
|
||||
UUID(as_uuid=True),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import time
|
||||
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 sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -75,6 +75,12 @@ def _dual_workplan_refs(
|
|||
"workstream_slug": slug,
|
||||
}
|
||||
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 (
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
OPEN_WORKPLAN_STATUSES,
|
||||
|
|
@ -100,12 +106,44 @@ def _summary_cache_headers(
|
|||
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)
|
||||
async def get_summary(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
refresh: bool = False,
|
||||
include_residuals: bool = Query(False),
|
||||
flavor: str | None = Query(None),
|
||||
) -> StateSummary:
|
||||
revision = await fetch_summary_revision(session)
|
||||
revision_token = revision.combined_fingerprint()
|
||||
|
|
@ -116,22 +154,38 @@ async def get_summary(
|
|||
|
||||
if cache_status == "hit-revision" and cached is not None:
|
||||
_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:
|
||||
result = await apply_progress_section(session, cached, revision)
|
||||
_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:
|
||||
cache.schedule_refresh(revision)
|
||||
_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)
|
||||
cache.store(result, revision)
|
||||
_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:
|
||||
|
|
@ -336,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
residual_open=0,
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
wait=task_counts.get(TaskStatus.wait, 0),
|
||||
|
|
@ -397,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
)
|
||||
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(
|
||||
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,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
ranked_suggestions=ranked_suggestions,
|
||||
open_workplans=open_workplan_rows,
|
||||
open_workplans=relevant_rows,
|
||||
residual_open_workplans=residual_rows,
|
||||
)
|
||||
return result
|
||||
|
||||
|
|
@ -521,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
w for w in workstreams_all
|
||||
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]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
|
|
@ -580,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
residual_open=residual_open,
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
wait=task_totals_by_status.get("wait", 0),
|
||||
|
|
@ -668,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
id=w.id,
|
||||
title=w.title,
|
||||
status=normalize_workplan_status(w.status),
|
||||
flavor=w.flavor,
|
||||
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"),
|
||||
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])
|
||||
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.
|
||||
|
||||
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)
|
||||
)
|
||||
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]
|
||||
dep_rows = []
|
||||
|
|
@ -944,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
if task.id in seen_task_ids:
|
||||
continue
|
||||
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)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
|
|
@ -988,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
Workplan.title,
|
||||
Workplan.slug,
|
||||
Workplan.topic_id,
|
||||
Workplan.flavor,
|
||||
).where(Workplan.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
|
|
@ -996,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
"title": title,
|
||||
"slug": slug,
|
||||
"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 = [
|
||||
|
|
@ -1023,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
|
||||
for from_ws_id in ready_from_ws_ids:
|
||||
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, [])
|
||||
if not todo_tasks:
|
||||
continue
|
||||
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
if is_residual_flavor(task.flavor):
|
||||
continue
|
||||
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
|
||||
_blocker_slugs = []
|
||||
for tid in dep_map[from_ws_id]:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
|||
|
||||
import yaml
|
||||
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 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.legacy_compat import retire_legacy_route
|
||||
from api.work_record_flavor import (
|
||||
RESIDUAL_FLAVOR,
|
||||
WORK_RECORD_FLAVORS,
|
||||
normalize_flavor,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
is_supported_workplan_status,
|
||||
normalize_workplan_status,
|
||||
|
|
@ -88,6 +93,8 @@ async def _list_workplans(
|
|||
status_filter: str | None,
|
||||
owner: str | None,
|
||||
slug: str | None,
|
||||
flavor: str | None = None,
|
||||
include_residuals: bool = True,
|
||||
session: AsyncSession,
|
||||
) -> list[Workplan]:
|
||||
q = select(Workplan)
|
||||
|
|
@ -106,6 +113,19 @@ async def _list_workplans(
|
|||
q = q.where(Workplan.owner == owner)
|
||||
if 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(
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
|
|
@ -365,6 +385,8 @@ async def list_workplans(
|
|||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
flavor: str | None = Query(None),
|
||||
include_residuals: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workplan]:
|
||||
return await _list_workplans(
|
||||
|
|
@ -374,6 +396,8 @@ async def list_workplans(
|
|||
status_filter=status,
|
||||
owner=owner,
|
||||
slug=slug,
|
||||
flavor=flavor,
|
||||
include_residuals=include_residuals,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class WorkstreamTotals(BaseModel):
|
|||
finished: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
residual_open: int = 0
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
|
|
@ -85,6 +86,7 @@ class StateSummary(BaseModel):
|
|||
blocked_tasks: list[TaskRead] = []
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workplans: list[WorkstreamWithDeps] = []
|
||||
residual_open_workplans: list[WorkstreamWithDeps] = []
|
||||
next_steps: list[NextStep] = []
|
||||
domains: list[DomainSummary] = []
|
||||
contribution_counts: dict[str, int] = {}
|
||||
|
|
@ -98,6 +100,7 @@ class DashboardWorkplanRow(BaseModel):
|
|||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
flavor: str | None = None
|
||||
domain: str = "unknown"
|
||||
repo_label: str = "unassigned"
|
||||
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.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||
from api.task_status import normalize_task_status
|
||||
from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor
|
||||
|
||||
|
||||
class TaskStatusMixin(BaseModel):
|
||||
|
|
@ -18,9 +19,23 @@ class TaskStatusMixin(BaseModel):
|
|||
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
|
||||
record_id: str | None = None
|
||||
flavor: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
|
|
@ -39,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
|||
return self
|
||||
|
||||
|
||||
class TaskUpdate(TaskStatusMixin):
|
||||
class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
|
||||
title: str | None = None
|
||||
flavor: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
priority: TaskPriority | None = None
|
||||
|
|
@ -102,6 +118,7 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
|||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
record_id: str | None = None
|
||||
flavor: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ from typing import Literal
|
|||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
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
|
||||
|
||||
WorkplanStatus = Literal[
|
||||
|
|
@ -28,7 +34,42 @@ class WorkplanStatusMixin(BaseModel):
|
|||
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
|
||||
repo_id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
|
|
@ -40,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | 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"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
|
|
@ -49,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin):
|
|||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkplanUpdate(WorkplanStatusMixin):
|
||||
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkplanStatus | None = None
|
||||
|
|
@ -57,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | 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
|
||||
launch_mode: LaunchMode | None = None
|
||||
concurrency_mode: ConcurrencyMode | None = None
|
||||
|
|
@ -95,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | 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"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
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"}),
|
||||
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
|
||||
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}
|
||||
<div class="filter-text-input">${owner}</div>
|
||||
${include_residuals}
|
||||
</div>`,
|
||||
}
|
||||
);
|
||||
|
|
@ -253,7 +255,8 @@ const filters = Generators.input(_filtersForm);
|
|||
const filtered = data.filter(w =>
|
||||
(filters.domain.length === 0 || filters.domain.includes(w.domain)) &&
|
||||
(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
|
||||
owner: 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
|
||||
their existing `state_hub_workstream_id` values. Write files first, then run
|
||||
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-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-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)
|
||||
|
||||
Usage:
|
||||
|
|
@ -118,6 +121,13 @@ from api.task_status import ( # noqa: E402
|
|||
TERMINAL_TASK_STATUSES,
|
||||
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")
|
||||
|
||||
|
|
@ -509,6 +519,36 @@ def _as_list(value: Any) -> list[str]:
|
|||
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:
|
||||
if value in (None, "", "~", "null", "None", "none"):
|
||||
return None
|
||||
|
|
@ -1664,6 +1704,86 @@ def check_repo(
|
|||
_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
|
||||
db_task_by_id: dict[str, dict] = {}
|
||||
if isinstance(db_tasks, list):
|
||||
|
|
@ -1688,7 +1808,7 @@ def check_repo(
|
|||
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")):
|
||||
for target_wp_id in _frontmatter_depends_on_workplans(meta):
|
||||
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
|
||||
if not target_ws_id:
|
||||
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)
|
||||
if not target_sh_id:
|
||||
report.add(
|
||||
|
|
@ -1849,6 +1969,36 @@ def check_repo(
|
|||
fixable=True,
|
||||
_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:
|
||||
# C-11: task exists in file but not linked to DB
|
||||
ws_status = ws.get("status", "")
|
||||
|
|
@ -3062,6 +3212,15 @@ def fix_repo(
|
|||
)
|
||||
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", {
|
||||
"id": desired_ws_id,
|
||||
"topic_id": topic_id,
|
||||
|
|
@ -3072,6 +3231,9 @@ def fix_repo(
|
|||
"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")),
|
||||
"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):
|
||||
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")
|
||||
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", {
|
||||
"id": desired_task_id,
|
||||
"workplan_id": new_ws_id,
|
||||
|
|
@ -3124,6 +3289,7 @@ def fix_repo(
|
|||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
"flavor": task_flavor,
|
||||
})
|
||||
if t_data and "_error" not in t_data:
|
||||
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']}"
|
||||
)
|
||||
|
||||
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":
|
||||
# T03 — writeback: DB is ahead of file — patch file to match DB.
|
||||
if no_writeback:
|
||||
|
|
|
|||
|
|
@ -1328,6 +1328,173 @@ class TestC20DependencyDetection:
|
|||
|
||||
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:
|
||||
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"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: infotech
|
||||
flavor: planning
|
||||
|
|
@ -62,7 +62,7 @@ parsing unknown frontmatter keys.
|
|||
|
||||
```task
|
||||
id: STATE-WP-0092-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c5c967ec-6f4c-541e-957d-7ae9489fbf6d"
|
||||
```
|
||||
|
|
@ -88,7 +88,7 @@ live leftover is equal open demand.
|
|||
|
||||
```task
|
||||
id: STATE-WP-0092-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0092-T01]
|
||||
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
|
||||
id: STATE-WP-0092-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0092-T02]
|
||||
state_hub_task_id: "0d53713b-d6f0-597d-8f87-9f86e5ca2235"
|
||||
|
|
@ -136,7 +136,7 @@ it.
|
|||
|
||||
```task
|
||||
id: STATE-WP-0092-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [STATE-WP-0092-T03]
|
||||
state_hub_task_id: "764a4b17-5119-572a-8aa1-581d719f07fa"
|
||||
|
|
@ -156,7 +156,7 @@ agent snippet exists for later instruction rollout.
|
|||
|
||||
```task
|
||||
id: STATE-WP-0092-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0092-T02, STATE-WP-0092-T03]
|
||||
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
|
||||
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