generated from coulomb/repo-seed
Complete workplan state model cleanup
This commit is contained in:
@@ -141,7 +141,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
||||
| Enum | Values |
|
||||
|------|--------|
|
||||
| `topic_status` | `active` · `paused` · `archived` |
|
||||
| `workstream_status` | `active` · `blocked` · `completed` · `archived` |
|
||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||
| `decision_type` | `made` · `pending` |
|
||||
@@ -169,7 +169,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
||||
"generated_at": "...",
|
||||
"totals": {
|
||||
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
||||
"workstreams": { "active": 1, "blocked": 0, "completed": 1, "total": 2 },
|
||||
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
|
||||
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
||||
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
||||
},
|
||||
|
||||
@@ -51,7 +51,10 @@ def _dependencies_any_incomplete(
|
||||
obj: dict[str, Any],
|
||||
values: list[Any],
|
||||
) -> bool:
|
||||
return bool(values) and any(value != assertion.value for value in values)
|
||||
expected = assertion.value
|
||||
if isinstance(expected, list):
|
||||
return bool(values) and any(value not in expected for value in values)
|
||||
return bool(values) and any(value != expected for value in values)
|
||||
|
||||
|
||||
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
|
||||
|
||||
@@ -19,6 +19,7 @@ from api.models.contribution import Contribution
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
@@ -104,11 +105,12 @@ async def _flow_object(
|
||||
) -> dict[str, Any]:
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
status = _value(entity.status)
|
||||
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
|
||||
obj: dict[str, Any] = {
|
||||
"id": str(entity.id),
|
||||
"status": status,
|
||||
"workstation": status,
|
||||
"previous_workstation": status,
|
||||
"status": current_status,
|
||||
"workstation": current_status,
|
||||
"previous_workstation": current_status,
|
||||
}
|
||||
|
||||
if entity_type == "workstream":
|
||||
@@ -127,7 +129,7 @@ async def _flow_object(
|
||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
||||
)).scalars().all())
|
||||
dependency_workstations = [
|
||||
{"id": str(ws.id), "workstation": ws.status}
|
||||
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
|
||||
for ws in dep_ws
|
||||
]
|
||||
obj.update({
|
||||
|
||||
@@ -38,6 +38,11 @@ from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.workplan_status import (
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status,
|
||||
)
|
||||
from task_flow_engine import FlowEngine
|
||||
|
||||
router = APIRouter(prefix="/state", tags=["state"])
|
||||
@@ -119,7 +124,7 @@ async def get_summary(
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
@@ -211,7 +216,7 @@ async def get_summary(
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": ws_lookup[d.to_workstream_id].status}
|
||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
],
|
||||
@@ -244,9 +249,16 @@ async def get_summary(
|
||||
total=sum(topic_counts.values()),
|
||||
),
|
||||
workstreams=WorkstreamTotals(
|
||||
proposed=ws_counts.get("proposed", 0),
|
||||
ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0),
|
||||
active=sum(1 for status in effective_status.values() if status == "active"),
|
||||
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
|
||||
completed=ws_counts.get("completed", 0),
|
||||
backlog=ws_counts.get("backlog", 0),
|
||||
finished=(
|
||||
ws_counts.get("finished", 0)
|
||||
+ ws_counts.get("completed", 0)
|
||||
+ ws_counts.get("accepted", 0)
|
||||
),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
),
|
||||
@@ -366,7 +378,7 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||
for domain_id, cnt in await session.execute(
|
||||
select(Topic.domain_id, func.count(Workstream.id))
|
||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||
.where(Workstream.status == "active")
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
.group_by(Topic.domain_id)
|
||||
):
|
||||
ws_per_domain[domain_id] = cnt
|
||||
@@ -405,7 +417,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
@@ -488,7 +500,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
|
||||
Two signal sources:
|
||||
1. Recently resolved decisions (last 7 days) → first open task in same workstream
|
||||
2. Workstreams whose every dependency is now completed → first todo task in that workstream
|
||||
2. Workstreams whose every dependency is now finished -> first todo task in that workstream
|
||||
"""
|
||||
steps: list[NextStep] = []
|
||||
seen_task_ids: set = set()
|
||||
@@ -575,8 +587,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
ready_from_ws_ids = [
|
||||
from_ws_id
|
||||
for from_ws_id, to_ws_ids in dep_map.items()
|
||||
if ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
|
||||
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
|
||||
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
||||
and all(
|
||||
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
||||
for to_id in to_ws_ids
|
||||
)
|
||||
]
|
||||
|
||||
todo_by_ws: dict = {}
|
||||
@@ -613,7 +628,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
task_id=task.id,
|
||||
task_title=task.title,
|
||||
message=(
|
||||
f"All dependencies of '{from_ws['title']}' are completed ({blocker_slugs}) → "
|
||||
f"All dependencies of '{from_ws['title']}' are finished ({blocker_slugs}) -> "
|
||||
f"'{task.title}' is ready to start"
|
||||
),
|
||||
))
|
||||
@@ -650,7 +665,7 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
||||
|
||||
Returns suggestions based on:
|
||||
- Recently resolved decisions → first open task in the same workstream
|
||||
- Workstreams whose every dependency workstream is now completed → first todo task
|
||||
- Workstreams whose every dependency workstream is now finished -> first todo task
|
||||
"""
|
||||
return await _derive_next_steps(session)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,9 +17,13 @@ from api.models.workstream import Workstream
|
||||
from api.schemas.workstream import (
|
||||
WorkstreamCreate,
|
||||
WorkstreamRead,
|
||||
WorkstreamStatus,
|
||||
WorkstreamUpdate,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
is_supported_workstream_status,
|
||||
normalize_workstream_status,
|
||||
ready_review_status,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||
|
||||
@@ -53,17 +58,10 @@ def _frontmatter(path: Path) -> dict[str, Any]:
|
||||
if end == -1:
|
||||
return {}
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
for raw_line in text[4:end].splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
data[key.strip()] = value
|
||||
return data
|
||||
try:
|
||||
return yaml.safe_load(text[4:end].strip()) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
@@ -71,7 +69,7 @@ async def list_workstreams(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
repo_goal_id: uuid.UUID | None = None,
|
||||
status: WorkstreamStatus | None = None,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -84,7 +82,10 @@ async def list_workstreams(
|
||||
if repo_goal_id:
|
||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
||||
if status:
|
||||
q = q.where(Workstream.status == status)
|
||||
normalised_status = normalize_workstream_status(status)
|
||||
if not is_supported_workstream_status(status):
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported workstream status '{status}'")
|
||||
q = q.where(Workstream.status == normalised_status)
|
||||
if owner:
|
||||
q = q.where(Workstream.owner == owner)
|
||||
if slug:
|
||||
@@ -127,11 +128,24 @@ async def workplan_index(
|
||||
workstream_id = data.get("state_hub_workstream_id")
|
||||
if not workstream_id:
|
||||
continue
|
||||
file_status = normalize_workstream_status(data.get("status", ""))
|
||||
review = (
|
||||
ready_review_status(
|
||||
root,
|
||||
data.get("reviewed_against_commit"),
|
||||
data.get("context_paths"),
|
||||
)
|
||||
if file_status == "ready"
|
||||
else None
|
||||
)
|
||||
index[str(workstream_id)] = {
|
||||
"filename": path.name,
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"repo_slug": repo.slug,
|
||||
"archived": archived,
|
||||
"status": file_status or None,
|
||||
"needs_review": bool(review and review.needs_review),
|
||||
"health_labels": ["needs_review"] if review and review.needs_review else [],
|
||||
}
|
||||
_INDEX_CACHE = {"workstreams": index}
|
||||
_INDEX_CACHE_AT = time.monotonic()
|
||||
@@ -176,7 +190,7 @@ async def update_workstream(
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
|
||||
if prev_status != "completed" and ws.status == "completed":
|
||||
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
||||
subject = "org.statehub.workstream.completed"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
|
||||
@@ -19,9 +19,12 @@ class TopicTotals(BaseModel):
|
||||
|
||||
|
||||
class WorkstreamTotals(BaseModel):
|
||||
proposed: int = 0
|
||||
ready: int = 0
|
||||
active: int = 0
|
||||
blocked: int = 0
|
||||
completed: int = 0
|
||||
backlog: int = 0
|
||||
finished: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
@@ -2,14 +2,30 @@ import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
WorkstreamStatus = Literal["todo", "active", "blocked", "completed", "archived"]
|
||||
WorkstreamStatus = Literal[
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
"blocked",
|
||||
"backlog",
|
||||
"finished",
|
||||
"archived",
|
||||
]
|
||||
|
||||
|
||||
class WorkstreamCreate(BaseModel):
|
||||
class WorkstreamStatusMixin(BaseModel):
|
||||
@field_validator("status", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_status(cls, value):
|
||||
return normalize_workstream_status(value)
|
||||
|
||||
|
||||
class WorkstreamCreate(WorkstreamStatusMixin):
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
@@ -23,7 +39,7 @@ class WorkstreamCreate(BaseModel):
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamUpdate(BaseModel):
|
||||
class WorkstreamUpdate(WorkstreamStatusMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus | None = None
|
||||
@@ -35,7 +51,7 @@ class WorkstreamUpdate(BaseModel):
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamRead(BaseModel):
|
||||
class WorkstreamRead(WorkstreamStatusMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID
|
||||
|
||||
169
api/workplan_status.py
Normal file
169
api/workplan_status.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
"blocked",
|
||||
"backlog",
|
||||
"finished",
|
||||
"archived",
|
||||
)
|
||||
|
||||
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
||||
"todo": "ready",
|
||||
"done": "finished",
|
||||
"completed": "finished",
|
||||
"accepted": "finished",
|
||||
}
|
||||
|
||||
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
*CANONICAL_WORKSTREAM_STATUSES,
|
||||
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
||||
)
|
||||
|
||||
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadyReviewStatus:
|
||||
needs_review: bool
|
||||
reason: str = ""
|
||||
changed_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||
"""Return the canonical lifecycle status for a stored or legacy value."""
|
||||
value = _status_value(status)
|
||||
if value == "todo" and has_started:
|
||||
return "active"
|
||||
return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value)
|
||||
|
||||
|
||||
def is_canonical_workstream_status(status: Any) -> bool:
|
||||
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
||||
|
||||
|
||||
def is_supported_workstream_status(status: Any) -> bool:
|
||||
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
||||
|
||||
|
||||
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
||||
|
||||
|
||||
def ready_review_status(
|
||||
repo_dir: str | Path,
|
||||
reviewed_against_commit: Any,
|
||||
context_paths: Any = None,
|
||||
) -> ReadyReviewStatus:
|
||||
"""Return whether a ready workplan needs review against current repo state.
|
||||
|
||||
When context paths are supplied, only changes under those paths matter.
|
||||
Missing or invalid git metadata is treated conservatively as needs review.
|
||||
"""
|
||||
reviewed = str(reviewed_against_commit or "").strip().strip("\"'")
|
||||
if not reviewed:
|
||||
return ReadyReviewStatus(False)
|
||||
|
||||
repo = Path(repo_dir)
|
||||
head = _git_output(repo, ["rev-parse", "HEAD"])
|
||||
if not head:
|
||||
return ReadyReviewStatus(True, "could not determine repository HEAD")
|
||||
if reviewed == head:
|
||||
return ReadyReviewStatus(False)
|
||||
|
||||
if not _git_commit_exists(repo, reviewed):
|
||||
return ReadyReviewStatus(True, f"review commit {reviewed[:12]} is not available")
|
||||
|
||||
patterns = _as_list(context_paths)
|
||||
if not patterns:
|
||||
return ReadyReviewStatus(
|
||||
True,
|
||||
f"reviewed against {reviewed[:12]}, current HEAD is {head[:12]}",
|
||||
)
|
||||
|
||||
changed = _changed_paths_since(repo, reviewed)
|
||||
if changed is None:
|
||||
return ReadyReviewStatus(True, "could not compare reviewed commit with HEAD")
|
||||
|
||||
matching = tuple(path for path in changed if _matches_any_context(path, patterns))
|
||||
if not matching:
|
||||
return ReadyReviewStatus(False)
|
||||
|
||||
return ReadyReviewStatus(
|
||||
True,
|
||||
f"{len(matching)} context path(s) changed since {reviewed[:12]}",
|
||||
matching,
|
||||
)
|
||||
|
||||
|
||||
def _status_value(status: Any) -> str:
|
||||
if hasattr(status, "value") and not isinstance(status, (str, bytes, bytearray)):
|
||||
status = status.value
|
||||
return str(status or "").strip().lower()
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip().replace("\\", "/") for item in value if str(item).strip()]
|
||||
if isinstance(value, str):
|
||||
return [item.strip().replace("\\", "/") for item in value.split(",") if item.strip()]
|
||||
return [str(value).strip().replace("\\", "/")]
|
||||
|
||||
|
||||
def _git_output(repo: Path, args: list[str]) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(repo), *args],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _git_commit_exists(repo: Path, commit: str) -> bool:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "cat-file", "-e", f"{commit}^{{commit}}"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _changed_paths_since(repo: Path, commit: str) -> tuple[str, ...] | None:
|
||||
output = _git_output(repo, ["diff", "--name-only", f"{commit}..HEAD", "--"])
|
||||
if output is None:
|
||||
return None
|
||||
return tuple(path.strip().replace("\\", "/") for path in output.splitlines() if path.strip())
|
||||
|
||||
|
||||
def _matches_any_context(path: str, patterns: list[str]) -> bool:
|
||||
norm_path = path.strip().replace("\\", "/").lstrip("./")
|
||||
for raw_pattern in patterns:
|
||||
pattern = raw_pattern.strip().replace("\\", "/").lstrip("./")
|
||||
if not pattern:
|
||||
continue
|
||||
if any(char in pattern for char in "*?[]"):
|
||||
if fnmatch.fnmatch(norm_path, pattern):
|
||||
return True
|
||||
elif norm_path == pattern or norm_path.startswith(f"{pattern.rstrip('/')}/"):
|
||||
return True
|
||||
return False
|
||||
@@ -131,8 +131,12 @@ function _ensureStyles() {
|
||||
|
||||
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
||||
const _STATUS_STYLE = {
|
||||
proposed: "background:#fef3c7;color:#92400e",
|
||||
ready: "background:#e0f2fe;color:#075985",
|
||||
active: "background:#d4edda;color:#155724",
|
||||
blocked: "background:#f8d7da;color:#721c24",
|
||||
backlog: "background:#f1f5f9;color:#64748b",
|
||||
finished: "background:#cce5ff;color:#004085",
|
||||
completed: "background:#cce5ff;color:#004085",
|
||||
archived: "background:#e2e3e5;color:#383d41",
|
||||
open: "background:#dbeafe;color:#1e40af",
|
||||
|
||||
45
dashboard/src/components/workplan-status.js
Normal file
45
dashboard/src/components/workplan-status.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export const WORKSTREAM_STATUSES = [
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
"blocked",
|
||||
"backlog",
|
||||
"finished",
|
||||
"archived",
|
||||
];
|
||||
|
||||
export const OPEN_WORKSTREAM_STATUSES = ["ready", "active", "blocked"];
|
||||
export const CLOSED_WORKSTREAM_STATUSES = ["finished", "archived"];
|
||||
|
||||
export const LEGACY_STATUS_ALIASES = {
|
||||
todo: "ready",
|
||||
done: "finished",
|
||||
completed: "finished",
|
||||
accepted: "finished",
|
||||
};
|
||||
|
||||
export function normalizeWorkstreamStatus(status) {
|
||||
const value = String(status ?? "").trim().toLowerCase();
|
||||
return LEGACY_STATUS_ALIASES[value] ?? value;
|
||||
}
|
||||
|
||||
export function isClosedWorkstream(status) {
|
||||
return CLOSED_WORKSTREAM_STATUSES.includes(normalizeWorkstreamStatus(status));
|
||||
}
|
||||
|
||||
export function isOpenWorkstream(status) {
|
||||
return OPEN_WORKSTREAM_STATUSES.includes(normalizeWorkstreamStatus(status));
|
||||
}
|
||||
|
||||
export function isStalledWorkstream(w, staleDays = 7) {
|
||||
const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
|
||||
const openTasks = (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0);
|
||||
return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
|
||||
&& new Date(w.updated_at) < staleAt
|
||||
&& (w.done ?? 0) > 0
|
||||
&& openTasks > 0;
|
||||
}
|
||||
|
||||
export function needsReviewWorkstream(w) {
|
||||
return Array.isArray(w.health_labels) && w.health_labels.includes("needs_review");
|
||||
}
|
||||
@@ -19,7 +19,16 @@ except urllib.error.URLError as e:
|
||||
"generated_at": None,
|
||||
"totals": {
|
||||
"topics": {"active": 0, "paused": 0, "archived": 0, "total": 0},
|
||||
"workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0},
|
||||
"workstreams": {
|
||||
"proposed": 0,
|
||||
"ready": 0,
|
||||
"active": 0,
|
||||
"blocked": 0,
|
||||
"backlog": 0,
|
||||
"finished": 0,
|
||||
"archived": 0,
|
||||
"total": 0,
|
||||
},
|
||||
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
|
||||
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ title: Dependencies
|
||||
|
||||
```js
|
||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
import {normalizeWorkstreamStatus} from "./components/workplan-status.js";
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -29,6 +30,7 @@ const depState = (async function*() {
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
||||
...w,
|
||||
status: normalizeWorkstreamStatus(w.status),
|
||||
// Prefer repo→domain (GEMS primary); fall back to topic→domain
|
||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
}]));
|
||||
@@ -87,7 +89,7 @@ injectTocTop("dep-kpi-box", _kpiBox);
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
```
|
||||
|
||||
Directed edges between active workstreams. An edge **A → B** means A cannot
|
||||
Directed edges between open workstreams. An edge **A → B** means A cannot
|
||||
fully proceed until B reaches a satisfactory state.
|
||||
|
||||
```js
|
||||
@@ -152,9 +154,12 @@ if (edges.length === 0) {
|
||||
.dep-title { font-weight: 500; max-width: 22rem; }
|
||||
.dep-arrow { text-align: center; color: var(--theme-foreground-faint, #bbb); font-size: 1rem; }
|
||||
.dep-status { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
||||
.dep-status-proposed { background: #fef3c7; color: #92400e; }
|
||||
.dep-status-ready { background: #e0f2fe; color: #075985; }
|
||||
.dep-status-active { background: #dcfce7; color: #166534; }
|
||||
.dep-status-completed { background: #f1f5f9; color: #475569; }
|
||||
.dep-status-blocked { background: #fee2e2; color: #991b1b; }
|
||||
.dep-status-backlog { background: #f1f5f9; color: #64748b; }
|
||||
.dep-status-finished { background: #f1f5f9; color: #475569; }
|
||||
.dep-status-archived { background: #f1f5f9; color: #9ca3af; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
</style>
|
||||
|
||||
@@ -244,7 +244,8 @@ The Overview page renders a horizontal stacked bar chart using `@observablehq/pl
|
||||
showing task counts (done / in progress / blocked / todo) per workstream.
|
||||
A `<select>` dropdown switches between:
|
||||
|
||||
- **Status modes**: active, accepted, finished, blocked, stalled, oldies
|
||||
- **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived
|
||||
- **Health modes**: needs review, stalled
|
||||
- **Time modes**: last 1h, 24h, 7d, 30d, today, this week, this month
|
||||
|
||||
Domains are sorted by most recent workstream activity (most active domain at
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Dependencies — Reference
|
||||
|
||||
# Dependencies — Reference
|
||||
|
||||
The Dependencies page shows the directed dependency graph between active
|
||||
The Dependencies page shows the directed dependency graph between open
|
||||
workstreams — which workstreams are waiting on others to reach a satisfactory
|
||||
state before they can fully proceed.
|
||||
|
||||
@@ -13,7 +13,7 @@ state before they can fully proceed.
|
||||
## What is a dependency edge?
|
||||
|
||||
A dependency edge **A → B** means workstream A cannot fully proceed until
|
||||
workstream B is in a satisfactory state (typically `completed` or `archived`).
|
||||
workstream B is in a satisfactory state (typically `finished` or `archived`).
|
||||
|
||||
Edges are used to model real sequencing constraints: for example, a shared
|
||||
library must reach a stable release before downstream domains can build on it.
|
||||
@@ -36,7 +36,7 @@ Each row shows:
|
||||
| **→** | Direction arrow |
|
||||
| **Blocked-by domain** | Domain of the prerequisite workstream |
|
||||
| **Blocked-by workstream** | Title of the workstream that must complete first |
|
||||
| **Status** | Current status of the prerequisite (green = active, grey = completed) |
|
||||
| **Status** | Current status of the prerequisite (green = active, grey = finished/archived) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Four metric cards:
|
||||
|
||||
| Card | Meaning |
|
||||
|------|---------|
|
||||
| **Active Workstreams** | Count of non-completed, non-archived workstreams |
|
||||
| **Active Workstreams** | Count of active/blocked execution workstreams |
|
||||
| **Blocking Decisions** | Pending decisions with status `open` or `escalated` — orange border if > 0 |
|
||||
| **Blocked Tasks** | Click to expand the list with blocking reasons |
|
||||
| **Events Today** | Progress events created on today's date |
|
||||
|
||||
@@ -39,7 +39,7 @@ These types are used by the State Hub's built-in write operations:
|
||||
| Type | When emitted |
|
||||
|---|---|
|
||||
| `workstream_created` | A new workstream was registered |
|
||||
| `workstream_status_changed` | Workstream moved to active / blocked / completed / archived |
|
||||
| `workstream_status_changed` | Workstream moved between canonical lifecycle states |
|
||||
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
|
||||
| `task_created` | A new task was added to a workstream |
|
||||
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled |
|
||||
|
||||
@@ -92,7 +92,7 @@ cd ~/ralph-workplan && ./install.sh --uninstall
|
||||
---
|
||||
id: WP-0001
|
||||
title: "Build a thing"
|
||||
status: active
|
||||
status: ready
|
||||
---
|
||||
|
||||
Optional description.
|
||||
@@ -120,7 +120,7 @@ priority: medium
|
||||
|-------|--------|-------------|
|
||||
| `id` | string | Unique workplan identifier |
|
||||
| `title` | string | Human-readable name |
|
||||
| `status` | `active` \| `done` \| `paused` | Workplan lifecycle state |
|
||||
| `status` | `proposed` \| `ready` \| `active` \| `blocked` \| `backlog` \| `finished` \| `archived` | Workplan lifecycle state |
|
||||
|
||||
**Task block fields:**
|
||||
|
||||
@@ -144,7 +144,7 @@ status: in_progress → status: done (when verified complete)
|
||||
When every task is `done`, Claude also updates the frontmatter:
|
||||
|
||||
```
|
||||
status: active → status: done
|
||||
status: active -> status: finished
|
||||
```
|
||||
|
||||
The loop detects this on the next iteration and stops.
|
||||
|
||||
@@ -72,7 +72,7 @@ autonomously. No human interaction is needed unless the agent has a question.
|
||||
|
||||
The [Repos](/repos) page shows each repo's integration status. An **integrating**
|
||||
badge appears on repos with an active Repo Integration workstream. The badge
|
||||
clears when the workstream is marked completed.
|
||||
clears when the workstream is marked finished.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,8 +120,8 @@ repo agent should:
|
||||
primary near-term work; register the workstream in the hub via MCP
|
||||
4. Execute T3 — ingest the SBOM so the repo appears green on the Repos page
|
||||
5. Execute T4 — a quick scan for obvious EPs/TDs; defer if nothing obvious
|
||||
6. Mark each task `done` in the hub as completed
|
||||
7. Mark the Repo Integration workstream `completed`
|
||||
6. Mark each task `done` in the hub
|
||||
7. Mark the Repo Integration workstream `finished`
|
||||
8. Log a progress event summarising the integration
|
||||
|
||||
The agent should resolve each task independently and in order. It does not
|
||||
|
||||
@@ -73,13 +73,13 @@ Detects concentration of blocking power. High SPR means one delay propagates wid
|
||||
### PEP — Parallel Execution Potential
|
||||
|
||||
```
|
||||
PEP = active workstreams with all deps completed / (active + blocked)
|
||||
PEP = ready or active workstreams with all deps finished / (ready + active + blocked)
|
||||
```
|
||||
|
||||
Estimates how much work can proceed right now. A workstream is eligible if its
|
||||
stored workstation label is `active` and the flow/dependency checks report no
|
||||
stored workstation label is `ready` or `active` and the flow/dependency checks report no
|
||||
unmet dependency assertion; practically, every workstream it depends on has
|
||||
reached `completed` or `archived`.
|
||||
reached `finished` or `archived`.
|
||||
|
||||
| PEP | Warning |
|
||||
|---|---|
|
||||
@@ -147,7 +147,7 @@ The domain breakdown is shown when at least two domains have active workstreams.
|
||||
| Symptom | Action |
|
||||
|---|---|
|
||||
| High DD | Decompose tightly coupled workstreams; remove unnecessary dependencies |
|
||||
| High BR | Unblock workstreams — resolve the blocking condition, or mark dependency as completed if done |
|
||||
| High BR | Unblock workstreams — resolve the blocking condition, or mark dependency as finished if done |
|
||||
| High SPR | Split the bottleneck workstream into independent deliverables |
|
||||
| Low PEP | Complete prerequisite workstreams or re-sequence work |
|
||||
| High CDDR | Refactor cross-domain dependencies into shared contracts or invert the dependency |
|
||||
|
||||
@@ -143,11 +143,11 @@ High SPR indicates fragile structure where one delay propagates widely.
|
||||
|
||||
A workstream is eligible if:
|
||||
|
||||
* Status = active
|
||||
* All dependencies are completed
|
||||
* Status = ready or active
|
||||
* All dependencies are finished or archived
|
||||
|
||||
[
|
||||
PEP = \frac{\text{Eligible active workstreams}}{\text{Active + Blocked}}
|
||||
PEP = \frac{\text{Eligible ready or active workstreams}}{\text{Ready + Active + Blocked}}
|
||||
]
|
||||
|
||||
---
|
||||
@@ -378,4 +378,3 @@ It captures both:
|
||||
* Operational flow conditions
|
||||
|
||||
By combining graph properties with status information, WHI enables proactive management of coordination complexity.
|
||||
|
||||
|
||||
@@ -1,111 +1,86 @@
|
||||
---
|
||||
title: Workstream Lifecycle — Reference
|
||||
title: Workstream Lifecycle - Reference
|
||||
---
|
||||
|
||||
# Workstream Lifecycle — Reference
|
||||
# Workstream Lifecycle - Reference
|
||||
|
||||
A workstream is an information object that occupies a named workstation. The
|
||||
stored `status` field keeps the current workstation label, while the
|
||||
task-flow engine derives which other workstations are reachable and which exit
|
||||
assertions are blocking movement. The dashboard "Workstreams by Domain" chart
|
||||
exposes stored and derived states as selectable filters so attention can be
|
||||
directed to the right workstreams at the right time.
|
||||
A workstream is an information object that occupies a named lifecycle state.
|
||||
The stored `status` field keeps that state, while the task-flow engine derives
|
||||
which other states are reachable and which exit assertions are blocking
|
||||
movement. Dashboard health filters such as `needs_review` and `stalled` are
|
||||
derived labels, not stored lifecycle values.
|
||||
|
||||
---
|
||||
|
||||
## Core workstations
|
||||
## Stored Lifecycle States
|
||||
|
||||
These are the primary workstations used by State Hub workstreams:
|
||||
|
||||
| Workstation | Source | Meaning |
|
||||
| State | Source | Meaning |
|
||||
|---|---|---|
|
||||
| **active** | DB `status = active` | Work is in progress or ready to start |
|
||||
| **finished** | Derived — no open tasks | All tasks are done, but no explicit review has taken place yet |
|
||||
| **accepted** | DB `status = completed` | Custodian and human have reviewed the workstream, quality checks passed, and it is formally signed off |
|
||||
| **proposed** | DB `status = proposed` | Plan exists, but must be reviewed against current repo state |
|
||||
| **ready** | DB `status = ready` | Plan has been reviewed and is ready to execute |
|
||||
| **active** | DB `status = active` | Work is in progress |
|
||||
| **blocked** | DB `status = blocked` | Work cannot proceed until a dependency, decision, or input clears |
|
||||
| **backlog** | DB `status = backlog` | Intentionally parked so it stays out of current work views |
|
||||
| **finished** | DB `status = finished` | Implementation is complete |
|
||||
| **archived** | DB `status = archived` | Historical record outside normal planning and execution |
|
||||
|
||||
The normal human-facing path is: **active → finished → accepted**.
|
||||
|
||||
`accepted` is the only state that requires an explicit action. It is reached by
|
||||
advancing the workstream to the `completed` workstation after deliberate
|
||||
review, not by task counts alone. This makes it a reliable anchor: anything in
|
||||
`finished` but not yet in `accepted` is work that still needs a quality pass.
|
||||
|
||||
---
|
||||
|
||||
## Attention signals
|
||||
|
||||
These signals are orthogonal to the core workstation — a workstream can be
|
||||
`active` and `stalled` at the same time. They serve as health indicators
|
||||
rather than stored lifecycle stages.
|
||||
|
||||
| Signal | Source | Meaning |
|
||||
|---|---|---|
|
||||
| **blocked** | Derived — unmet exit assertion or blocked task | Work cannot currently leave its workstation; inspect `blocked_reasons` |
|
||||
| **stalled** | Derived — `updated_at` > 7 days ago, has both done and open tasks | Work started but activity has stopped; needs a nudge |
|
||||
| **oldies** | Derived — `created_at` > 7 days ago, zero done tasks | Workstream is old and nothing has been completed yet; may need re-evaluation |
|
||||
|
||||
---
|
||||
|
||||
## The acceptance quality gate
|
||||
|
||||
When a workstream reaches **finished** (all tasks done), the custodian's role is to:
|
||||
|
||||
1. Review the deliverables against the workstream's stated purpose and scope
|
||||
2. Check for missing tests, documentation, or follow-up issues
|
||||
3. Create tasks for any gaps found — this moves the workstream back to **active**
|
||||
4. Once satisfied, advance to the `completed` workstation — this marks it as **accepted**
|
||||
|
||||
This pattern ensures that "done" and "accepted" are distinct signals.
|
||||
`finished` is a fact about task counts; `accepted` is a statement of quality.
|
||||
Normal progression:
|
||||
|
||||
```text
|
||||
backlog -> proposed -> ready -> active -> finished -> archived
|
||||
\ \
|
||||
\ -> blocked -> active
|
||||
-> backlog
|
||||
```
|
||||
# Inspect and accept a workstream via MCP
|
||||
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="completed")
|
||||
|
||||
# Or via REST
|
||||
---
|
||||
|
||||
## Health Labels
|
||||
|
||||
| Label | Source | Meaning |
|
||||
|---|---|---|
|
||||
| **needs_review** | Ready-review metadata + git diff | A `ready` workplan may be stale because relevant files changed since review |
|
||||
| **stalled** | Task counts + timestamp | Work started, but there has been no meaningful progress after the threshold |
|
||||
|
||||
`needs_review` and `stalled` can appear beside lifecycle states. They should
|
||||
not be written into workplan frontmatter or directly into the workstream
|
||||
`status` field.
|
||||
|
||||
---
|
||||
|
||||
## Ready Review Metadata
|
||||
|
||||
Ready workplans may include optional frontmatter:
|
||||
|
||||
```yaml
|
||||
reviewed_at: "YYYY-MM-DD"
|
||||
reviewed_by: "human-or-agent"
|
||||
reviewed_against_commit: "<git-sha>"
|
||||
context_paths:
|
||||
- "path/or/glob"
|
||||
```
|
||||
|
||||
If `reviewed_against_commit` differs from `HEAD`, State Hub checks
|
||||
`context_paths` when present. Relevant changes produce the derived
|
||||
`needs_review` label. Automatic demotion from `ready` to `proposed` is guarded
|
||||
behind explicit tooling, not done silently.
|
||||
|
||||
---
|
||||
|
||||
## Flow Operations
|
||||
|
||||
```text
|
||||
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="finished")
|
||||
```
|
||||
|
||||
Direct status patching still exists for bootstrap and compatibility work:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "completed"}'
|
||||
-d '{"status": "finished"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Time-based filters
|
||||
|
||||
The chart also supports time-window filters that cut across all lifecycle states:
|
||||
|
||||
| Filter | Shows workstreams where… |
|
||||
|---|---|
|
||||
| **last 1 hour** | `updated_at` or `created_at` within the last 60 minutes |
|
||||
| **last 24 hours** | … within the last 24 hours |
|
||||
| **last 7 days** | … within the last 7 days |
|
||||
| **last 30 days** | … within the last 30 days |
|
||||
| **today** | … since midnight today |
|
||||
| **this week** | … since Monday of the current week |
|
||||
| **this month** | … since the 1st of the current month |
|
||||
|
||||
In time-based views, workstream labels are **bold** for accepted and blocked
|
||||
workstreams to distinguish notable states at a glance.
|
||||
|
||||
---
|
||||
|
||||
## Stored Label Vs. Dashboard State
|
||||
|
||||
The DB stores a single `status` field on each workstream. Treat it as the
|
||||
current workstation label. The dashboard maps this alongside flow-engine
|
||||
results, dependency assertions, and task-count data to produce the richer set
|
||||
of filter states:
|
||||
|
||||
| Dashboard state | Stored label / derived source | Condition |
|
||||
|---|---|---|
|
||||
| active | `status = active` | — |
|
||||
| accepted | `status = completed` | — |
|
||||
| finished | task counts | `todo + in_progress + blocked = 0` |
|
||||
| blocked | flow result / task counts | `exit_blocked = true` or `blocked ≥ 1` |
|
||||
| stalled | task counts + timestamp | `done ≥ 1` and `open ≥ 1` and `updated_at > 7d ago` |
|
||||
| oldies | task counts + timestamp | `done = 0` and `open ≥ 1` and `created_at > 7d ago` |
|
||||
|
||||
*Workstreams are never hard-deleted — use `advance_workstation(...,
|
||||
"completed")` or advance/patch to `"archived"` to close them without losing
|
||||
history.*
|
||||
Workstreams are never hard-deleted. Use `finished` for completed
|
||||
implementation and `archived` for historical records outside normal planning.
|
||||
|
||||
@@ -20,14 +20,17 @@ as filters change.
|
||||
|
||||
| Workstation | Meaning |
|
||||
|---|---|
|
||||
| **active** | Work in progress or ready to start |
|
||||
| **proposed** | Plan exists, but needs review against current repo state |
|
||||
| **ready** | Reviewed and ready to execute |
|
||||
| **active** | Work is in progress |
|
||||
| **blocked** | Stored blocker label; the State Hub can also derive blocked state from unmet exit assertions |
|
||||
| **completed** | Formally accepted after custodian review (shown as **accepted** in the overview chart) |
|
||||
| **archived** | Closed without completion; no longer relevant |
|
||||
| **backlog** | Intentionally parked for later |
|
||||
| **finished** | Implementation is complete |
|
||||
| **archived** | Closed historical record |
|
||||
|
||||
See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full task-flow
|
||||
model including derived states (finished, stalled, oldies) and assertion-based
|
||||
blocking.
|
||||
model including derived health labels (`needs_review`, `stalled`) and
|
||||
assertion-based blocking.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +94,7 @@ create_workstream(
|
||||
topic_id = "<uuid>",
|
||||
title = "Build user authentication",
|
||||
description = "JWT-based auth, refresh tokens, middleware",
|
||||
status = "active",
|
||||
status = "ready",
|
||||
owner = "human",
|
||||
due_date = "2026-04-01"
|
||||
)
|
||||
@@ -102,7 +105,7 @@ Via REST:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/workstreams/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic_id": "<uuid>", "title": "…", "status": "active"}'
|
||||
-d '{"topic_id": "<uuid>", "title": "…", "status": "ready"}'
|
||||
```
|
||||
|
||||
---
|
||||
@@ -111,7 +114,7 @@ curl -X POST http://127.0.0.1:8000/workstreams/ \
|
||||
|
||||
```
|
||||
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="completed")
|
||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="finished")
|
||||
```
|
||||
|
||||
Movement is flow-aware: the task-flow engine evaluates the target
|
||||
|
||||
@@ -4,6 +4,13 @@ title: Overview
|
||||
|
||||
```js
|
||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
import {
|
||||
WORKSTREAM_STATUSES,
|
||||
isClosedWorkstream,
|
||||
isStalledWorkstream,
|
||||
needsReviewWorkstream,
|
||||
normalizeWorkstreamStatus,
|
||||
} from "./components/workplan-status.js";
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -52,11 +59,13 @@ const pageState = (async function*() {
|
||||
const workplan = workplanMap[w.id] ?? {};
|
||||
return {
|
||||
...w,
|
||||
status: normalizeWorkstreamStatus(w.status),
|
||||
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
|
||||
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
|
||||
workplan_filename: workplan.filename ?? null,
|
||||
workplan_relative_path: workplan.relative_path ?? null,
|
||||
workplan_archived: workplan.archived ?? false,
|
||||
health_labels: workplan.health_labels ?? [],
|
||||
href: `./workstreams/${w.id}`,
|
||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
||||
};
|
||||
@@ -126,13 +135,18 @@ display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}"
|
||||
// view() is the idiomatic Observable Framework reactive input:
|
||||
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
||||
const _chartMode = view(html`<select class="ws-mode-select">
|
||||
<optgroup label="By Status">
|
||||
<option value="active" selected>active</option>
|
||||
<option value="accepted">accepted</option>
|
||||
<option value="finished">finished</option>
|
||||
<optgroup label="Lifecycle">
|
||||
<option value="ready" selected>ready</option>
|
||||
<option value="active">active</option>
|
||||
<option value="blocked">blocked</option>
|
||||
<option value="proposed">proposed</option>
|
||||
<option value="backlog">backlog</option>
|
||||
<option value="finished">finished</option>
|
||||
<option value="archived">archived</option>
|
||||
</optgroup>
|
||||
<optgroup label="Health">
|
||||
<option value="needs_review">needs review</option>
|
||||
<option value="stalled">stalled</option>
|
||||
<option value="oldies">oldies</option>
|
||||
</optgroup>
|
||||
<optgroup label="Recently Changed">
|
||||
<option value="1h">last 1 hour</option>
|
||||
@@ -150,12 +164,11 @@ const _chartMode = view(html`<select class="ws-mode-select">
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
// ── Filter workstreams by selected mode ───────────────────────────────────────
|
||||
// "active" matches the DB status field directly.
|
||||
// "accepted" = DB status "completed" (explicitly reviewed and signed off).
|
||||
// "finished" = no open tasks remaining (derived from task counts).
|
||||
// "blocked" = has ≥1 blocked task; "stalled" / "oldies" = activity-based.
|
||||
// Lifecycle modes match stored canonical status values.
|
||||
// Health modes are derived labels; they are not stored lifecycle states.
|
||||
// Time modes filter by updated_at / created_at.
|
||||
const _STATUS_MODES = new Set(["active"]);
|
||||
const _STATUS_MODES = new Set(WORKSTREAM_STATUSES);
|
||||
const _HEALTH_MODES = new Set(["needs_review", "stalled"]);
|
||||
|
||||
function _timeCutoff(mode) {
|
||||
const now = new Date();
|
||||
@@ -175,27 +188,11 @@ function _timeCutoff(mode) {
|
||||
|
||||
const _chartWsFiltered = (
|
||||
_STATUS_MODES.has(_chartMode)
|
||||
? wsAll.filter(w => w.status === _chartMode)
|
||||
: _chartMode === "accepted"
|
||||
? wsAll.filter(w => w.status === "completed")
|
||||
: _chartMode === "finished"
|
||||
? wsAll.filter(w => (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) === 0)
|
||||
: _chartMode === "blocked"
|
||||
? wsAll.filter(w => (w.blocked ?? 0) > 0)
|
||||
? wsAll.filter(w => normalizeWorkstreamStatus(w.status) === _chartMode)
|
||||
: _chartMode === "needs_review"
|
||||
? wsAll.filter(needsReviewWorkstream)
|
||||
: _chartMode === "stalled"
|
||||
? wsAll.filter(w => {
|
||||
const staleAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(w.updated_at) < staleAt
|
||||
&& (w.done ?? 0) > 0
|
||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
||||
})
|
||||
: _chartMode === "oldies"
|
||||
? wsAll.filter(w => {
|
||||
const oldAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(w.created_at) < oldAt
|
||||
&& (w.done ?? 0) === 0
|
||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
||||
})
|
||||
? wsAll.filter(isStalledWorkstream)
|
||||
: (() => {
|
||||
const since = _timeCutoff(_chartMode);
|
||||
return wsAll.filter(w =>
|
||||
@@ -215,9 +212,9 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => {
|
||||
});
|
||||
|
||||
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
||||
// Color is NOT used for status — avoids green-on-green when completed bars fill the row.
|
||||
const _isTimeBased = !_STATUS_MODES.has(_chartMode);
|
||||
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; }
|
||||
// Color is NOT used for status — avoids green-on-green when finished bars fill the row.
|
||||
const _isTimeBased = !_STATUS_MODES.has(_chartMode) && !_HEALTH_MODES.has(_chartMode);
|
||||
function _wsWeight(s) { return (isClosedWorkstream(s) || normalizeWorkstreamStatus(s) === "blocked") ? "bold" : "normal"; }
|
||||
|
||||
// ── y-axis: domain/repo label for first workstream per repository only ────────
|
||||
const _yLabels = {};
|
||||
@@ -251,10 +248,15 @@ function _wsTitle(d) {
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
if (chartWs.length === 0) {
|
||||
const _emptyMsg = {
|
||||
active: "No active workstreams.", accepted: "No accepted workstreams.",
|
||||
finished: "No finished workstreams.", blocked: "No blocked workstreams.",
|
||||
proposed: "No proposed workstreams.",
|
||||
ready: "No ready workstreams.",
|
||||
active: "No active workstreams.",
|
||||
blocked: "No blocked workstreams.",
|
||||
backlog: "No backlog workstreams.",
|
||||
finished: "No finished workstreams.",
|
||||
archived: "No archived workstreams.",
|
||||
needs_review: "No ready workstreams need review.",
|
||||
stalled: "No stalled workstreams — everything is moving.",
|
||||
oldies: "No oldies — all older workstreams have at least one task done.",
|
||||
"1h": "No workstreams changed in the last hour.",
|
||||
"1d": "No workstreams changed in the last 24 hours.",
|
||||
"7d": "No workstreams changed in the last 7 days.",
|
||||
|
||||
@@ -4,6 +4,7 @@ title: Workstreams
|
||||
|
||||
```js
|
||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
import {WORKSTREAM_STATUSES, isClosedWorkstream, normalizeWorkstreamStatus} from "./components/workplan-status.js";
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -27,6 +28,7 @@ const wsState = (async function*() {
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
data = wsList.map(w => ({
|
||||
...w,
|
||||
status: normalizeWorkstreamStatus(w.status),
|
||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
topic_title: topicMap[w.topic_id]?.title ?? "—",
|
||||
}));
|
||||
@@ -50,7 +52,7 @@ const _ts = wsState.ts;
|
||||
```js
|
||||
// ── Workstream Health Index (WHI) ────────────────────────────────────────────
|
||||
const _idToDomain = Object.fromEntries(data.map(w => [w.id, w.domain ?? "unknown"]));
|
||||
const _completedIds = new Set(data.filter(w => w.status === "completed" || w.status === "archived").map(w => w.id));
|
||||
const _closedIds = new Set(data.filter(w => isClosedWorkstream(w.status)).map(w => w.id));
|
||||
const _openCount = openWs.length;
|
||||
const _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id})));
|
||||
const _totalEdges = _allEdges.length;
|
||||
@@ -64,15 +66,15 @@ const _BR = _openCount > 0 ? openWs.filter(w => w.status === "blocked").length /
|
||||
// Single-Point Risk — max inbound edges on one incomplete workstream
|
||||
const _inbound = {};
|
||||
for (const e of _allEdges) {
|
||||
if (!_completedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1;
|
||||
if (!_closedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1;
|
||||
}
|
||||
const _SPR = _openCount > 0
|
||||
? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount
|
||||
: 0;
|
||||
|
||||
// Parallel Execution Potential — active workstreams with all deps completed
|
||||
// Parallel Execution Potential — ready/active workstreams with all deps finished
|
||||
const _PEP = _openCount > 0
|
||||
? openWs.filter(w => w.status === "active" && w.depends_on.every(d => _completedIds.has(d.workstream_id))).length / _openCount
|
||||
? openWs.filter(w => ["ready", "active"].includes(normalizeWorkstreamStatus(w.status)) && w.depends_on.every(d => _closedIds.has(d.workstream_id))).length / _openCount
|
||||
: 0;
|
||||
|
||||
// Cross-Domain Dependency Ratio
|
||||
@@ -117,9 +119,9 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
|
||||
const dd = oc > 0 ? te / oc : 0;
|
||||
const br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0;
|
||||
const pep = oc > 0 ? nodes.filter(w => {
|
||||
if (w.status !== "active") return false;
|
||||
if (!["ready", "active"].includes(normalizeWorkstreamStatus(w.status))) return false;
|
||||
const intraDeps = w.depends_on.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain);
|
||||
return intraDeps.every(d => _completedIds.has(d.workstream_id));
|
||||
return intraDeps.every(d => _closedIds.has(d.workstream_id));
|
||||
}).length / oc : 0;
|
||||
const inb = {};
|
||||
for (const e of edges) inb[e.to] = (inb[e.to] ?? 0) + 1;
|
||||
@@ -222,7 +224,7 @@ const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => nu
|
||||
const DOMAINS = _domainsResp?.ok
|
||||
? (await _domainsResp.json()).map(d => d.slug)
|
||||
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const STATUSES = ["active", "blocked", "completed", "archived"];
|
||||
const STATUSES = WORKSTREAM_STATUSES;
|
||||
|
||||
// Create filter form without displaying — shown below the chart
|
||||
const _filtersForm = Inputs.form(
|
||||
@@ -357,7 +359,10 @@ if (wsWithDeps.length === 0) {
|
||||
.dep-status { display: inline-block; font-size: 0.7rem; padding: 1px 6px; border-radius: 10px; margin-bottom: 0.5rem; text-transform: uppercase; }
|
||||
.dep-status-active { background: #d4edda; color: #155724; }
|
||||
.dep-status-blocked { background: #f8d7da; color: #721c24; }
|
||||
.dep-status-completed { background: #cce5ff; color: #004085; }
|
||||
.dep-status-proposed { background: #fef3c7; color: #92400e; }
|
||||
.dep-status-ready { background: #e0f2fe; color: #075985; }
|
||||
.dep-status-finished { background: #cce5ff; color: #004085; }
|
||||
.dep-status-backlog { background: #f1f5f9; color: #64748b; }
|
||||
.dep-row { font-size: 0.85rem; margin: 0.2rem 0 0 0.5rem; color: #444; }
|
||||
.dep-on { color: #1a5276; }
|
||||
.dep-block { color: #6e2f00; }
|
||||
|
||||
@@ -16,7 +16,7 @@ keeps the underlying scripts; only the *scheduling* moves.
|
||||
| # | Source | Trigger today | Script invoked | What it does |
|
||||
| - | ------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 1 | systemd user timer | every 15 min | `scripts/consistency_check.py --remote --all` | Pull every registered repo, reconcile workplan files ↔ DB, run C-15 writeback + C-16 pull gate |
|
||||
| 2 | manual / daily cron | `make cleanup-stale` (suggested `0 3 * * *`) | `scripts/cleanup_stale_tasks.py` | Cancel tasks still open in completed/archived workstreams; emits `org.statehub.task.stale` |
|
||||
| 2 | manual / daily cron | `make cleanup-stale` (suggested `0 3 * * *`) | `scripts/cleanup_stale_tasks.py` | Cancel tasks still open in finished/archived workstreams; emits `org.statehub.task.stale` |
|
||||
| 3 | git post-commit | every commit in a registered repo | `make fix-consistency REPO=<slug>` | Per-repo workplan ↔ DB sync immediately after a commit |
|
||||
|
||||
Honourable mentions (not currently scheduled, on-demand only — listed for
|
||||
@@ -79,7 +79,7 @@ Notes:
|
||||
id: the-custodian.state-hub-stale-task-cleanup
|
||||
description: |
|
||||
Daily sweep that cancels tasks still 'todo|in_progress|blocked' inside
|
||||
completed or archived workstreams. Each cancellation also emits
|
||||
finished or archived workstreams. Each cancellation also emits
|
||||
org.statehub.task.stale on NATS for downstream reaction.
|
||||
trigger:
|
||||
trigger_type: cron
|
||||
|
||||
@@ -42,7 +42,7 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
|
||||
| Subject | When | Required attributes |
|
||||
| ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` |
|
||||
| `org.statehub.workstream.completed` | A workstream transitions to status `completed` | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
|
||||
| `org.statehub.workstream.completed` | A workstream transitions to canonical status `finished` | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
|
||||
| `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` |
|
||||
| `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` |
|
||||
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` |
|
||||
|
||||
@@ -129,10 +129,10 @@ blocking_assertions:
|
||||
passed: false
|
||||
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
|
||||
reachable:
|
||||
- todo
|
||||
- ready
|
||||
- active
|
||||
unreachable:
|
||||
- workstation: completed
|
||||
- workstation: finished
|
||||
blocking:
|
||||
id: tasks.all_done
|
||||
passed: false
|
||||
@@ -151,9 +151,9 @@ Schema:
|
||||
|
||||
### Workstreams
|
||||
|
||||
Workstreams can express readiness for completion by asserting that child tasks
|
||||
Workstreams can express readiness for closure by asserting that child tasks
|
||||
are `done` or `cancelled`. They can express dependency blocking by checking that
|
||||
all dependency workstreams have reached `completed`.
|
||||
all dependency workstreams have reached `finished` or `archived`.
|
||||
|
||||
### Tasks
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type: workplan
|
||||
title: "Short Title"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: active
|
||||
status: proposed
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
```
|
||||
@@ -22,3 +22,14 @@ topic_slug: custodian
|
||||
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.
|
||||
|
||||
Canonical workplan/workstream statuses are:
|
||||
|
||||
```text
|
||||
proposed, ready, active, blocked, backlog, finished, archived
|
||||
```
|
||||
|
||||
Use `proposed` for a new plan that still needs review, `ready` after it has
|
||||
been checked against the current repo state, and `finished` when implementation
|
||||
is complete. `stalled` and `needs_review` are derived health labels, not stored
|
||||
frontmatter statuses.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
id: custodian.workstream.v1
|
||||
entity_type: workstream
|
||||
workstations:
|
||||
- name: todo
|
||||
description: Planned but not yet active.
|
||||
- name: proposed
|
||||
description: Plan exists but needs review against the current repo state.
|
||||
entry_assertions: []
|
||||
exit_assertions: []
|
||||
- name: ready
|
||||
description: Reviewed and ready to execute.
|
||||
entry_assertions: []
|
||||
exit_assertions: []
|
||||
- name: active
|
||||
@@ -12,23 +16,33 @@ workstations:
|
||||
- id: dependencies.all_complete
|
||||
target: dependencies.*.workstation
|
||||
op: all_eq
|
||||
value: completed
|
||||
description: Dependency workstreams have reached completed.
|
||||
value:
|
||||
- finished
|
||||
- archived
|
||||
description: Dependency workstreams have reached a closed state.
|
||||
- name: blocked
|
||||
description: Work is blocked by incomplete dependencies or missing input.
|
||||
entry_assertions:
|
||||
- id: dependencies.any_incomplete
|
||||
target: dependencies.*.workstation
|
||||
op: custom
|
||||
value: completed
|
||||
description: At least one dependency is not completed.
|
||||
value:
|
||||
- finished
|
||||
- archived
|
||||
description: At least one dependency is not finished or archived.
|
||||
exit_assertions:
|
||||
- id: dependencies.all_complete
|
||||
target: dependencies.*.workstation
|
||||
op: all_eq
|
||||
value: completed
|
||||
description: All dependency workstreams have reached completed.
|
||||
- name: completed
|
||||
value:
|
||||
- finished
|
||||
- archived
|
||||
description: All dependency workstreams have reached finished or archived.
|
||||
- name: backlog
|
||||
description: Intentionally parked for later.
|
||||
entry_assertions: []
|
||||
exit_assertions: []
|
||||
- name: finished
|
||||
description: Work is complete.
|
||||
entry_assertions:
|
||||
- id: tasks.all_done
|
||||
@@ -40,6 +54,6 @@ workstations:
|
||||
description: All child tasks are done or cancelled.
|
||||
exit_assertions: []
|
||||
- name: archived
|
||||
description: Completed work has been moved out of the active set.
|
||||
description: Closed work has been moved out of the active set.
|
||||
entry_assertions: []
|
||||
exit_assertions: []
|
||||
|
||||
@@ -57,7 +57,7 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
||||
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
|
||||
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
|
||||
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
|
||||
| `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | Thin shortcut — use `update_workstream` for full field control. |
|
||||
| `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. |
|
||||
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
||||
|
||||
---
|
||||
|
||||
@@ -757,7 +757,7 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
|
||||
|
||||
Args:
|
||||
workstream_id: UUID of the workstream
|
||||
status: active | blocked | completed | archived
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
"""
|
||||
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
|
||||
_post("/progress", {
|
||||
@@ -789,7 +789,7 @@ def update_workstream(
|
||||
owner: new owner (optional)
|
||||
due_date: ISO date string YYYY-MM-DD (optional)
|
||||
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
|
||||
status: active | blocked | completed | archived (optional)
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
|
||||
"""
|
||||
payload: dict = {}
|
||||
if title is not None:
|
||||
@@ -818,7 +818,7 @@ def get_next_steps() -> str:
|
||||
|
||||
Returns suggestions based on:
|
||||
- Recently resolved decisions → first open task in the same workstream
|
||||
- Workstreams whose every dependency is now completed → first todo task
|
||||
- Workstreams whose every dependency is now finished -> first todo task
|
||||
|
||||
Each suggestion includes domain, workstream, task, and a plain-language
|
||||
message. The hub surfaces *what* and *where* — the domain owns *how*.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""normalize workstream lifecycle states
|
||||
|
||||
Revision ID: u8p9q0r1s2t3
|
||||
Revises: t7o8p9q0r1s2
|
||||
Create Date: 2026-05-17
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "u8p9q0r1s2t3"
|
||||
down_revision = "t7o8p9q0r1s2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE workstreams
|
||||
SET status = 'finished'
|
||||
WHERE status IN ('completed', 'accepted', 'done')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE workstreams ws
|
||||
SET status = 'active'
|
||||
WHERE status = 'todo'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks t
|
||||
WHERE t.workstream_id = ws.id
|
||||
AND t.status <> 'todo'
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE workstreams
|
||||
SET status = 'ready'
|
||||
WHERE status = 'todo'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE workstreams SET status = 'completed' WHERE status = 'finished'")
|
||||
op.execute("UPDATE workstreams SET status = 'active' WHERE status IN ('proposed', 'ready', 'backlog')")
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cleanup_stale_tasks.py — cancel tasks that are still open in completed/archived workstreams.
|
||||
cleanup_stale_tasks.py — cancel tasks that are still open in finished/archived workstreams.
|
||||
|
||||
Run manually: python3 scripts/cleanup_stale_tasks.py
|
||||
Run via make: make cleanup-stale
|
||||
@@ -22,6 +22,8 @@ from datetime import datetime, timezone
|
||||
# Make the api package importable when running as `python scripts/cleanup_stale_tasks.py`
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||
|
||||
try:
|
||||
from api.events import EventEnvelope, publish_event, shutdown_publisher
|
||||
except Exception: # pragma: no cover — event publishing is optional
|
||||
@@ -31,7 +33,7 @@ except Exception: # pragma: no cover — event publishing is optional
|
||||
|
||||
API = "http://127.0.0.1:8000"
|
||||
STALE_STATUSES = {"todo", "in_progress", "blocked"}
|
||||
CLOSED_WS_STATUS = {"completed", "archived"}
|
||||
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
|
||||
|
||||
|
||||
def get(path: str) -> list | dict:
|
||||
@@ -81,7 +83,11 @@ def main() -> int:
|
||||
print("[cleanup-stale] Start the API with: cd ~/state-hub && make api", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
closed_ws = {w["id"]: w for w in workstreams if w["status"] in CLOSED_WS_STATUS}
|
||||
closed_ws = {
|
||||
w["id"]: w
|
||||
for w in workstreams
|
||||
if normalize_workstream_status(w["status"]) in CLOSED_WS_STATUS
|
||||
}
|
||||
|
||||
stale = [
|
||||
t for t in tasks
|
||||
@@ -93,7 +99,7 @@ def main() -> int:
|
||||
print("[cleanup-stale] Nothing to cancel — all open tasks belong to active workstreams.")
|
||||
return 0
|
||||
|
||||
print(f"[cleanup-stale] Found {len(stale)} stale task(s) in completed/archived workstreams:")
|
||||
print(f"[cleanup-stale] Found {len(stale)} stale task(s) in finished/archived workstreams:")
|
||||
|
||||
cancelled = []
|
||||
errors = []
|
||||
@@ -150,7 +156,7 @@ def main() -> int:
|
||||
|
||||
summary = (
|
||||
f"Stale-task cleanup: cancelled {len(cancelled)} task(s) "
|
||||
f"across {len(by_ws)} completed workstream(s)"
|
||||
f"across {len(by_ws)} finished workstream(s)"
|
||||
)
|
||||
detail = {
|
||||
"cancelled_count": len(cancelled),
|
||||
|
||||
@@ -12,7 +12,7 @@ Checks:
|
||||
C-05 workstream-title-drift WARN Yes File title != DB title (file wins)
|
||||
C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id
|
||||
C-07 orphan-db-active FAIL No Active DB workstream, no backing file
|
||||
C-08 orphan-db-completed INFO No Completed/archived DB workstream, no file
|
||||
C-08 orphan-db-closed INFO No Finished/archived DB workstream, no file
|
||||
C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location
|
||||
C-10 task-status-drift WARN Yes Task status differs between file and DB
|
||||
C-11 task-unlinked WARN Yes Task block has no state_hub_task_id
|
||||
@@ -51,6 +51,20 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from api.workplan_status import ( # noqa: E402
|
||||
CANONICAL_WORKSTREAM_STATUSES,
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
LEGACY_WORKSTREAM_STATUS_ALIASES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
SUPPORTED_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status as _normalize_workstream_status,
|
||||
ready_review_status,
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
_HAS_YAML = True
|
||||
@@ -71,19 +85,15 @@ except ImportError:
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
|
||||
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
||||
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
||||
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
|
||||
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
||||
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
|
||||
|
||||
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
|
||||
# "completed". This map translates file values to DB values before comparison
|
||||
# and before PATCHing, so "done" vs "completed" is never flagged as C-04 drift.
|
||||
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = {
|
||||
"done": "completed",
|
||||
"todo": "active", # workplan not yet started → active workstream in DB
|
||||
}
|
||||
# Legacy file/API aliases translated before comparison and PATCHing.
|
||||
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
|
||||
|
||||
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
|
||||
# blocked and in_progress share rank 1 — both are "in flight".
|
||||
@@ -96,9 +106,9 @@ STATUS_ORDER: dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
def normalise_workstream_status(status: str) -> str:
|
||||
def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str:
|
||||
"""Translate a workplan file status value to its DB-canonical equivalent."""
|
||||
return FILE_TO_DB_WORKSTREAM_STATUS.get(status, status)
|
||||
return _normalize_workstream_status(status, has_started=has_started)
|
||||
|
||||
|
||||
def canonical_workplan_filename(path: Path) -> str:
|
||||
@@ -593,10 +603,11 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
file_title = str(meta.get("title", "")).strip()
|
||||
file_domain = str(meta.get("domain", "")).strip()
|
||||
|
||||
if archived_file and normalise_workstream_status(file_status) == "active":
|
||||
normalised_file_status = normalise_workstream_status(file_status)
|
||||
if archived_file and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
report.add(
|
||||
severity="FAIL", check_id="C-18",
|
||||
message="Archived workplan file has active/todo status",
|
||||
message="Archived workplan file has an open or planning status",
|
||||
file_path=fname,
|
||||
file_value=file_status,
|
||||
fixable=False,
|
||||
@@ -652,10 +663,10 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
# Continue to check drift even with mismatched repo
|
||||
|
||||
# C-04: status drift — normalise file value before comparing so that
|
||||
# "done" (file) vs "completed" (DB) is not treated as drift.
|
||||
# legacy file/API aliases are not treated as drift.
|
||||
db_status = ws.get("status", "")
|
||||
normalised_file_status = normalise_workstream_status(file_status)
|
||||
if file_status and db_status and normalised_file_status != db_status:
|
||||
normalised_db_status = normalise_workstream_status(db_status)
|
||||
if file_status and db_status and normalised_file_status != normalised_db_status:
|
||||
report.add(
|
||||
severity="WARN", check_id="C-04",
|
||||
message=(
|
||||
@@ -674,6 +685,28 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
},
|
||||
)
|
||||
|
||||
if normalised_file_status == "ready":
|
||||
review = ready_review_status(
|
||||
repo_dir,
|
||||
meta.get("reviewed_against_commit"),
|
||||
meta.get("context_paths"),
|
||||
)
|
||||
if review.needs_review:
|
||||
detail = f"Ready workplan may be stale: {review.reason}"
|
||||
if review.changed_paths:
|
||||
preview = ", ".join(review.changed_paths[:5])
|
||||
extra = "" if len(review.changed_paths) <= 5 else ", ..."
|
||||
detail = f"{detail}; changed paths: {preview}{extra}"
|
||||
report.add(
|
||||
severity="WARN",
|
||||
check_id="C-21",
|
||||
message=detail,
|
||||
file_path=fname,
|
||||
file_value=file_status,
|
||||
db_value="needs_review",
|
||||
fixable=False,
|
||||
)
|
||||
|
||||
# C-05: title drift
|
||||
db_title = ws.get("title", "")
|
||||
if file_title and db_title and file_title != db_title:
|
||||
@@ -888,7 +921,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
# C-12: DB tasks with no file backing
|
||||
if isinstance(db_tasks, list):
|
||||
ws_status = ws.get("status", "")
|
||||
ws_finished = ws_status in ("completed", "archived")
|
||||
ws_finished = normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES
|
||||
for db_t in db_tasks:
|
||||
if db_t["id"] not in file_task_sh_ids:
|
||||
db_t_status = db_t.get("status", "")
|
||||
@@ -912,7 +945,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
|
||||
# C-13: all DB tasks done but workstream still active — worker forgot to close
|
||||
db_status = ws.get("status", "")
|
||||
if db_status == "active" and isinstance(db_tasks, list) and db_tasks:
|
||||
if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks:
|
||||
non_terminal = [
|
||||
t for t in db_tasks
|
||||
if t.get("status") not in ("done", "cancelled")
|
||||
@@ -932,7 +965,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
_fix_context={
|
||||
"ws_id": ws_id,
|
||||
"field": "status",
|
||||
"value": "completed",
|
||||
"value": "finished",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -963,26 +996,27 @@ def _check_orphan_db(
|
||||
for ws in all_ws:
|
||||
ws_id = ws["id"]
|
||||
ws_status = ws.get("status", "")
|
||||
if ws_status == "active" and ws_id in active_file_ws_ids:
|
||||
normalised_status = normalise_workstream_status(ws_status)
|
||||
if normalised_status not in CLOSED_WORKSTREAM_STATUSES and ws_id in active_file_ws_ids:
|
||||
continue
|
||||
if ws_status in ("completed", "archived") and ws_id in file_ws_ids:
|
||||
if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids:
|
||||
continue
|
||||
ws_slug = ws.get("slug", "")
|
||||
if ws_status == "active":
|
||||
if normalised_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
report.add(
|
||||
severity="FAIL", check_id="C-07",
|
||||
message=(
|
||||
f"Active DB workstream '{ws_slug}' (id={ws_id[:8]}…) "
|
||||
f"Non-closed DB workstream '{ws_slug}' (id={ws_id[:8]}…) "
|
||||
f"has no backing workplan file — ADR-001 violation"
|
||||
),
|
||||
db_id=ws_id,
|
||||
fixable=False,
|
||||
)
|
||||
elif ws_status in ("completed", "archived"):
|
||||
elif normalised_status in CLOSED_WORKSTREAM_STATUSES:
|
||||
report.add(
|
||||
severity="INFO", check_id="C-08",
|
||||
message=(
|
||||
f"Completed/archived DB workstream '{ws_slug}' "
|
||||
f"Closed DB workstream '{ws_slug}' "
|
||||
f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file"
|
||||
),
|
||||
db_id=ws_id,
|
||||
@@ -1019,9 +1053,11 @@ def _check_ghost_duplicates(
|
||||
topic_ids.add(ws["topic_id"])
|
||||
|
||||
for topic_id in topic_ids:
|
||||
topic_ws = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": "active"})
|
||||
if not isinstance(topic_ws, list):
|
||||
continue
|
||||
topic_ws: list[dict] = []
|
||||
for status in OPEN_WORKSTREAM_STATUSES:
|
||||
status_rows = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": status})
|
||||
if isinstance(status_rows, list):
|
||||
topic_ws.extend(status_rows)
|
||||
for ws in topic_ws:
|
||||
ws_id = ws["id"]
|
||||
if ws_id in file_ws_ids:
|
||||
@@ -1166,9 +1202,13 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
|
||||
domain_slug: str = ""
|
||||
|
||||
# Resolve domain slug: prefer active workstreams, fall back to any workstream
|
||||
# so that a fully-completed repo doesn't degrade to "(unknown)".
|
||||
workstreams = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": "active"}) or []
|
||||
_ws_for_domain = workstreams if (isinstance(workstreams, list) and workstreams) else []
|
||||
# so that a fully-finished repo doesn't degrade to "(unknown)".
|
||||
workstreams: list[dict] = []
|
||||
for status in OPEN_WORKSTREAM_STATUSES:
|
||||
rows = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": status}) or []
|
||||
if isinstance(rows, list):
|
||||
workstreams.extend(rows)
|
||||
_ws_for_domain = workstreams if workstreams else []
|
||||
if not _ws_for_domain:
|
||||
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or []
|
||||
_ws_for_domain = all_ws if isinstance(all_ws, list) else []
|
||||
@@ -1379,7 +1419,8 @@ def fix_repo(
|
||||
wp_id = str(meta.get("id", "")).strip()
|
||||
title = str(meta.get("title", "")).strip()
|
||||
status = str(meta.get("status", "active")).strip()
|
||||
if status not in ("active", "completed", "archived"):
|
||||
status = normalise_workstream_status(status)
|
||||
if status not in VALID_WP_STATUSES:
|
||||
status = "active"
|
||||
|
||||
# Find topic_id for this domain
|
||||
@@ -1500,7 +1541,7 @@ def fix_repo(
|
||||
t_id = str(task.get("id", "")).strip()
|
||||
# Skip creating tasks for finished workstreams — the workstream is
|
||||
# done/archived so unlinked tasks are stale file artefacts, not gaps.
|
||||
if ws_status in ("completed", "archived"):
|
||||
if normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES:
|
||||
report.fixes_applied.append(
|
||||
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
|
||||
)
|
||||
@@ -1596,7 +1637,7 @@ def fix_repo(
|
||||
|
||||
|
||||
# Check IDs that are known-background noise in multi-machine setups:
|
||||
# C-08 = completed/archived DB workstream with no file (pre-ADR-001 legacy)
|
||||
# C-08 = finished/archived DB workstream with no file (pre-ADR-001 legacy)
|
||||
# These alone do not warrant a pull+fix cycle.
|
||||
_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"})
|
||||
|
||||
@@ -1707,7 +1748,7 @@ def archive_closed_workplans(
|
||||
) -> list[str]:
|
||||
"""Move closed root workplans into workplans/archived/ with YYMMDD prefix.
|
||||
|
||||
Only root-level files whose frontmatter status normalises to completed or
|
||||
Only root-level files whose frontmatter status normalises to finished or
|
||||
archived are moved. Files with any open task blocks are left in place.
|
||||
"""
|
||||
repo_dir = Path(repo_path)
|
||||
@@ -1732,7 +1773,7 @@ def archive_closed_workplans(
|
||||
if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}:
|
||||
continue
|
||||
status = normalise_workstream_status(str(meta.get("status", "")).strip())
|
||||
if status not in ("completed", "archived"):
|
||||
if status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
continue
|
||||
tasks = get_tasks_from_workplan(meta, body)
|
||||
open_tasks = [
|
||||
|
||||
@@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
**Start:**
|
||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
|
||||
3. Scan workplans: `ls workplans/` — note `status: active` files and open tasks
|
||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
||||
|
||||
**During work:**
|
||||
@@ -108,7 +108,7 @@ read/cache/index layer that rebuilds from files.
|
||||
|
||||
**File location:** `workplans/{WP_PREFIX}-NNNN-<slug>.md`
|
||||
|
||||
**Archived location:** completed workplans may move to
|
||||
**Archived location:** finished workplans may move to
|
||||
`workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||
the completion/archive date; the frontmatter `id` does not change.
|
||||
|
||||
@@ -126,7 +126,7 @@ type: workplan
|
||||
title: "..."
|
||||
domain: {DOMAIN}
|
||||
repo: {REPO_SLUG}
|
||||
status: active | done
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
topic_slug: ...
|
||||
created: "YYYY-MM-DD"
|
||||
@@ -135,6 +135,10 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
---
|
||||
```
|
||||
|
||||
Use `proposed` for a new draft, `ready` after review against current repo
|
||||
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||
derived health labels, not frontmatter statuses.
|
||||
|
||||
**Task block format** (one per `##` section):
|
||||
|
||||
```
|
||||
|
||||
@@ -25,7 +25,8 @@ requests before proceeding.
|
||||
```bash
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: active`, note pending `todo`/`in_progress` tasks.
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`todo`/`in_progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ ID prefix: `{WP_PREFIX}`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
Canonical workplan/workstream frontmatter statuses are:
|
||||
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||
repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-{REPO_SLUG}-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
@@ -40,6 +40,16 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from api.workplan_status import ( # noqa: E402
|
||||
CANONICAL_WORKSTREAM_STATUSES,
|
||||
SUPPORTED_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status,
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
_HAS_YAML = True
|
||||
@@ -58,7 +68,8 @@ except ImportError:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
|
||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
||||
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
||||
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
|
||||
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||
|
||||
@@ -198,11 +209,14 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
|
||||
|
||||
# status
|
||||
status = str(meta.get("status", ""))
|
||||
if status not in VALID_WP_STATUSES:
|
||||
if status not in SUPPORTED_WP_STATUSES:
|
||||
report.add(Level.FAIL, "frontmatter-status",
|
||||
f"status must be one of {sorted(VALID_WP_STATUSES)}, got {status!r}", fname)
|
||||
f"status must be one of {sorted(VALID_WP_STATUSES)} "
|
||||
f"(legacy aliases accepted: {sorted(SUPPORTED_WP_STATUSES - VALID_WP_STATUSES)}), "
|
||||
f"got {status!r}", fname)
|
||||
else:
|
||||
report.add(Level.PASS, "frontmatter-status", f"status={status}", fname)
|
||||
report.add(Level.PASS, "frontmatter-status",
|
||||
f"status={normalize_workstream_status(status)}", fname)
|
||||
|
||||
# id format
|
||||
wp_id = str(meta.get("id", ""))
|
||||
@@ -363,7 +377,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None,
|
||||
continue
|
||||
for ws in workstreams:
|
||||
ws_status = ws.get("status", "")
|
||||
if ws_status in ("completed", "archived"):
|
||||
if normalize_workstream_status(ws_status) in {"finished", "archived"}:
|
||||
continue
|
||||
ws_id = ws["id"]
|
||||
ws_slug = ws.get("slug", "")
|
||||
|
||||
@@ -40,6 +40,7 @@ from consistency_check import (
|
||||
render_text,
|
||||
report_to_dict,
|
||||
)
|
||||
from api.workplan_status import ready_review_status
|
||||
# _detect_behind_remote and _git_pull are re-exported from consistency_check
|
||||
# for backward compat; their canonical implementations live in repo_sync.py.
|
||||
|
||||
@@ -403,17 +404,22 @@ class TestReportToDict:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormaliseWorkstreamStatus:
|
||||
"""FILE_TO_DB_WORKSTREAM_STATUS maps workplan file vocabulary to DB vocabulary.
|
||||
"""Legacy workplan/API vocabulary maps to the canonical lifecycle model."""
|
||||
|
||||
Workplan files use task-style "done"; the DB workstream API uses "completed".
|
||||
The C-04 check and fix code must normalise before comparing or PATCHing.
|
||||
"""
|
||||
def test_done_maps_to_finished(self):
|
||||
assert normalise_workstream_status("done") == "finished"
|
||||
|
||||
def test_done_maps_to_completed(self):
|
||||
assert normalise_workstream_status("done") == "completed"
|
||||
def test_completed_maps_to_finished(self):
|
||||
assert normalise_workstream_status("completed") == "finished"
|
||||
|
||||
def test_completed_is_identity(self):
|
||||
assert normalise_workstream_status("completed") == "completed"
|
||||
def test_accepted_maps_to_finished(self):
|
||||
assert normalise_workstream_status("accepted") == "finished"
|
||||
|
||||
def test_todo_maps_to_ready_by_default(self):
|
||||
assert normalise_workstream_status("todo") == "ready"
|
||||
|
||||
def test_todo_maps_to_active_when_started(self):
|
||||
assert normalise_workstream_status("todo", has_started=True) == "active"
|
||||
|
||||
def test_active_is_identity(self):
|
||||
assert normalise_workstream_status("active") == "active"
|
||||
@@ -428,12 +434,12 @@ class TestNormaliseWorkstreamStatus:
|
||||
# Don't crash on unexpected values — return them unchanged
|
||||
assert normalise_workstream_status("foobar") == "foobar"
|
||||
|
||||
def test_map_constant_covers_done(self):
|
||||
assert "done" in FILE_TO_DB_WORKSTREAM_STATUS
|
||||
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "completed"
|
||||
def test_map_constant_covers_legacy_aliases(self):
|
||||
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "finished"
|
||||
assert FILE_TO_DB_WORKSTREAM_STATUS["completed"] == "finished"
|
||||
|
||||
def test_c04_no_spurious_drift_when_done_vs_completed(self):
|
||||
"""done (file) vs completed (DB) must NOT be reported as C-04 drift."""
|
||||
def test_c04_no_spurious_drift_when_done_vs_finished(self):
|
||||
"""done (file) vs finished (DB) must NOT be reported as C-04 drift."""
|
||||
assert normalise_workstream_status("done") == normalise_workstream_status("completed")
|
||||
|
||||
def test_c04_real_drift_still_detected(self):
|
||||
@@ -441,6 +447,55 @@ class TestNormaliseWorkstreamStatus:
|
||||
assert normalise_workstream_status("done") != normalise_workstream_status("active")
|
||||
|
||||
|
||||
class TestReadyReviewStatus:
|
||||
def _repo_with_commit(self, tmp_path):
|
||||
import subprocess
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "-C", str(repo), "init"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True)
|
||||
tracked = repo / "src" / "app.py"
|
||||
tracked.parent.mkdir()
|
||||
tracked.write_text("print('one')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-m", "init"], check=True, capture_output=True)
|
||||
base = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
|
||||
return repo, tracked, base
|
||||
|
||||
def test_same_commit_is_current(self, tmp_path):
|
||||
repo, _tracked, base = self._repo_with_commit(tmp_path)
|
||||
|
||||
result = ready_review_status(repo, base)
|
||||
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_changed_context_path_needs_review(self, tmp_path):
|
||||
import subprocess
|
||||
repo, tracked, base = self._repo_with_commit(tmp_path)
|
||||
tracked.write_text("print('two')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-m", "change app"], check=True, capture_output=True)
|
||||
|
||||
result = ready_review_status(repo, base, ["src"])
|
||||
|
||||
assert result.needs_review is True
|
||||
assert result.changed_paths == ("src/app.py",)
|
||||
|
||||
def test_unrelated_context_path_does_not_need_review(self, tmp_path):
|
||||
import subprocess
|
||||
repo, _tracked, base = self._repo_with_commit(tmp_path)
|
||||
docs = repo / "docs" / "note.md"
|
||||
docs.parent.mkdir()
|
||||
docs.write_text("note\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-m", "docs"], check=True, capture_output=True)
|
||||
|
||||
result = ready_review_status(repo, base, ["src"])
|
||||
|
||||
assert result.needs_review is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STATUS_ORDER / no-regress rule (T01 / C-15)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -109,9 +109,18 @@ class TestWorkstreams:
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
|
||||
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "finished"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "finished"
|
||||
|
||||
async def test_legacy_completed_status_is_normalized(self, client):
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
|
||||
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "completed"
|
||||
assert r.json()["status"] == "finished"
|
||||
|
||||
async def test_filter_by_owner(self, client):
|
||||
await _create_domain(client)
|
||||
@@ -321,11 +330,11 @@ class TestFlowEndpoints:
|
||||
|
||||
r = await client.get(f"/flows/workstream/{ws['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "completed" in r.json()["reachable"]
|
||||
assert "finished" in r.json()["reachable"]
|
||||
|
||||
r = await client.post(f"/flows/workstream/{ws['id']}/advance/completed")
|
||||
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["current_workstation"] == "completed"
|
||||
assert r.json()["current_workstation"] == "finished"
|
||||
|
||||
r = await client.get(f"/workstreams/{ws['id']}")
|
||||
assert r.json()["status"] == "completed"
|
||||
assert r.json()["status"] == "finished"
|
||||
|
||||
@@ -12,7 +12,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
||||
workstations=[
|
||||
WorkstationDef(name="active"),
|
||||
WorkstationDef(
|
||||
name="completed",
|
||||
name="finished",
|
||||
entry_assertions=[
|
||||
AssertionDef(
|
||||
id="tasks.all_done",
|
||||
@@ -31,7 +31,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
||||
)
|
||||
|
||||
assert result.exit_blocked is False
|
||||
assert result.reachable == ["active", "completed"]
|
||||
assert result.reachable == ["active", "finished"]
|
||||
assert result.unreachable == []
|
||||
|
||||
|
||||
@@ -135,18 +135,18 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
|
||||
workstream_result = FlowEngine(
|
||||
custom_ops={
|
||||
"dependencies.any_incomplete": lambda assertion, obj, values: any(
|
||||
value != assertion.value for value in values
|
||||
value not in assertion.value for value in values
|
||||
)
|
||||
}
|
||||
).evaluate(
|
||||
{
|
||||
"status": "active",
|
||||
"tasks": [{"status": "done"}],
|
||||
"dependencies": [{"workstation": "completed"}],
|
||||
"dependencies": [{"workstation": "finished"}],
|
||||
},
|
||||
flows["workstream"],
|
||||
)
|
||||
assert "completed" in workstream_result.reachable
|
||||
assert "finished" in workstream_result.reachable
|
||||
assert "blocked" in [item.workstation for item in workstream_result.unreachable]
|
||||
|
||||
task_result = FlowEngine().evaluate(
|
||||
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "Workplan State Model Cleanup"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: active
|
||||
status: finished
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
planning_priority: high
|
||||
@@ -89,7 +89,7 @@ reviewed as the design source for this workplan.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "9be18f7e-1db1-464b-8c65-bf64ae3462e8"
|
||||
```
|
||||
@@ -105,7 +105,7 @@ string sets in multiple files.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1d9964ce-7b30-49d3-a4e3-6d5b3ef8d684"
|
||||
```
|
||||
@@ -124,7 +124,7 @@ normalized without data loss.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c80df776-5d45-419d-a92c-59e3b77d9798"
|
||||
```
|
||||
@@ -140,7 +140,7 @@ values only when they intentionally edit files.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "97ebc285-3998-4f17-bf43-c4f803cf1e7b"
|
||||
```
|
||||
@@ -163,7 +163,7 @@ an explicit flag.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "210fcff3-e3e9-4f8a-9687-c5de83ace465"
|
||||
```
|
||||
@@ -178,7 +178,7 @@ Done when the overview no longer uses `accepted`, `oldies`, or derived
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "8ecceebb-0471-4541-96f9-c9f98df12f84"
|
||||
```
|
||||
@@ -193,7 +193,7 @@ API and consistency engine.
|
||||
|
||||
```task
|
||||
id: CUST-WP-0042-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7dd1f27d-4a4a-4c78-8b91-6103688559a9"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user