feat(classification-spine): implement STATE-WP-0065 repo-anchored model

Replace the ad-hoc coordination-domain spine with the Repo Classification
Standard: 14 market domains, classification columns on managed_repos, and
workplans anchored by repo_id (topic_id optional).

- Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename
- Add api/classification.py validation and register-from-classification tooling
- Expose workplan-first REST/MCP surface with legacy workstream aliases
- Add C-24 consistency rule and legacy domain frontmatter mapping
- Update dashboard repos page with category/capability/stake filters
- Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
2026-06-22 13:52:13 +02:00
parent 279be4ffbd
commit 0949d4c0d8
84 changed files with 4494 additions and 1111 deletions

290
api/classification.py Normal file
View File

@@ -0,0 +1,290 @@
"""Repo classification validation for State Hub registration (STATE-WP-0065 P1).
Loads allowed values from the custodian canon standard and validates classification
blocks against controlled vocabularies.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
# Primary path (sibling checkout); fallback relative to state-hub repo root.
_PRIMARY_ALLOWED = Path(
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
)
_FALLBACK_ALLOWED = (
Path(__file__).resolve().parent.parent.parent
/ "the-custodian"
/ "canon"
/ "standards"
/ "repo-classification.allowed.yaml"
)
@dataclass
class ClassificationData:
"""Normalized classification fields stored on ``managed_repos``."""
category: str
domain: str
secondary_domains: list[str] = field(default_factory=list)
capability_tags: list[str] = field(default_factory=list)
business_stake: list[str] = field(default_factory=list)
business_mechanics: list[str] = field(default_factory=list)
classified_at: str | None = None
classified_by: str | None = None
standard_version: str | None = None
def to_dict(self) -> dict:
return {
"category": self.category,
"domain": self.domain,
"secondary_domains": list(self.secondary_domains),
"capability_tags": list(self.capability_tags),
"business_stake": list(self.business_stake),
"business_mechanics": list(self.business_mechanics),
"classified_at": self.classified_at,
"classified_by": self.classified_by,
"standard_version": self.standard_version,
}
@classmethod
def from_block(cls, block: dict) -> ClassificationData:
return cls(
category=block["category"],
domain=block["domain"],
secondary_domains=list(block.get("secondary_domains") or []),
capability_tags=list(block.get("capability_tags") or []),
business_stake=list(block.get("business_stake") or []),
business_mechanics=list(block.get("business_mechanics") or []),
classified_at=block.get("classified_at"),
classified_by=block.get("classified_by"),
standard_version=block.get("version") or block.get("standard_version"),
)
def _allowed_path() -> Path:
if _PRIMARY_ALLOWED.is_file():
return _PRIMARY_ALLOWED
if _FALLBACK_ALLOWED.is_file():
return _FALLBACK_ALLOWED
raise FileNotFoundError(
"repo-classification.allowed.yaml not found at "
f"{_PRIMARY_ALLOWED} or {_FALLBACK_ALLOWED}"
)
def load_allowed_values(path: Path | None = None) -> dict:
"""Load the machine-readable allowed-values YAML."""
target = path or _allowed_path()
with target.open(encoding="utf-8") as fh:
return yaml.safe_load(fh)
def _known_capability_tags(allowed: dict) -> set[str]:
tags: set[str] = set()
for fam in (allowed.get("capability_families") or {}).values():
tags.update(fam or [])
return tags
def validate_classification(block: dict) -> tuple[list[str], list[str]]:
"""Validate a ``repo_classification`` block.
Returns ``(errors, warnings)``. *block* should be the inner mapping (not the
full YAML document with the ``repo_classification`` wrapper).
"""
allowed = load_allowed_values()
errors: list[str] = []
warnings: list[str] = []
if not isinstance(block, dict):
return (["classification block must be a mapping"], [])
categories = set(allowed["categories"])
domains = set(allowed["domains"])
stakes = set(allowed["business_stake"])
mechanics = set(allowed["business_mechanics"])
guidance = allowed.get("guidance", {})
pattern = re.compile(
guidance.get("capability_tag_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
)
category = block.get("category")
if category is None:
errors.append("`category` is required")
elif category not in categories:
errors.append(f"`category` '{category}' not in {sorted(categories)}")
domain = block.get("domain")
if domain is None:
errors.append("`domain` is required")
elif domain not in domains:
errors.append(f"`domain` '{domain}' not in allowed domains")
secondary = block.get("secondary_domains") or []
if not isinstance(secondary, list):
errors.append("`secondary_domains` must be a list")
secondary = []
for d in secondary:
if d not in domains:
errors.append(f"secondary domain '{d}' not in allowed domains")
if d == domain:
errors.append(f"secondary domain '{d}' repeats the primary domain")
if len(secondary) != len(set(secondary)):
errors.append("`secondary_domains` contains duplicates")
smax = guidance.get("secondary_domains_max", 3)
if len(secondary) > smax:
warnings.append(
f"{len(secondary)} secondary_domains exceeds recommended max {smax}"
)
tags = block.get("capability_tags") or []
if not isinstance(tags, list):
errors.append("`capability_tags` must be a list")
tags = []
known = _known_capability_tags(allowed)
for t in tags:
if not isinstance(t, str) or not pattern.match(t):
errors.append(f"capability_tag '{t}' is not lowercase kebab-case")
elif t not in known:
warnings.append(
f"capability_tag '{t}' is not a recommended family tag "
"(allowed, check for synonym)"
)
stake = block.get("business_stake") or []
if not isinstance(stake, list):
errors.append("`business_stake` must be a list")
stake = []
for s in stake:
if s not in stakes:
errors.append(f"business_stake '{s}' not in {sorted(stakes)}")
if stake:
lo = guidance.get("business_stake_recommended_min", 2)
hi = guidance.get("business_stake_recommended_max", 6)
if not (lo <= len(stake) <= hi):
warnings.append(
f"{len(stake)} business_stake values; {lo}-{hi} recommended"
)
mech = block.get("business_mechanics") or []
if not isinstance(mech, list):
errors.append("`business_mechanics` must be a list")
mech = []
for m in mech:
if m not in mechanics:
errors.append(f"business_mechanics '{m}' not in {sorted(mechanics)}")
return errors, warnings
CLASSIFICATION_FILENAME = ".repo-classification.yaml"
# Market-domain slugs (Repo Classification Standard v1.0 §6).
MARKET_DOMAIN_SLUGS: frozenset[str] = frozenset({
"infotech",
"financials",
"communication",
"consumer",
"health",
"industrials",
"energy",
"utilities",
"materials",
"realestate",
"crypto",
"agents",
"space",
"government",
})
# Legacy coordination-domain slugs still found in workplan frontmatter ``domain:``.
# Maps to market-domain slugs used by the Hub ``domains`` table post-migration.
LEGACY_COORDINATION_TO_MARKET: dict[str, str] = {
"custodian": "infotech",
"railiance": "financials",
"markitect": "communication",
"coulomb_social": "communication",
"personhood": "government",
"foerster_capabilities": "agents",
"capabilities": "agents",
"canon": "infotech",
"citation_evidence": "infotech",
"helix_forge": "infotech",
"inter_hub": "infotech",
"netkingdom": "communication",
"stack": "infotech",
"vergabe_teilnahme": "government",
"whynot": "consumer",
"test_domain_v2": "infotech",
}
def resolve_topic_domain_slug(
workplan_domain: str,
*,
repo_market_domain: str | None = None,
) -> str:
"""Map a workplan frontmatter ``domain`` value to a market-domain slug.
Workplans may still carry legacy coordination slugs (e.g. ``custodian``)
after the spine migration; topic lookup must use the market domain stored
on ``domains.slug``.
"""
domain = (workplan_domain or "").strip()
if not domain:
return repo_market_domain or ""
if domain in MARKET_DOMAIN_SLUGS:
return domain
mapped = LEGACY_COORDINATION_TO_MARKET.get(domain)
if mapped:
return mapped
return repo_market_domain or domain
def load_classification_document(path: Path) -> dict | None:
"""Load and return the YAML document, or ``None`` if missing/unreadable."""
if not path.is_file():
return None
try:
with path.open(encoding="utf-8") as fh:
doc = yaml.safe_load(fh)
except (OSError, yaml.YAMLError):
return None
return doc if isinstance(doc, dict) else None
def extract_classification_block(doc: dict | None) -> dict | None:
"""Return the inner ``repo_classification`` mapping from a loaded document."""
if not doc:
return None
block = doc.get("repo_classification")
return block if isinstance(block, dict) else None
def load_classification_file(
repo_path: Path | str,
*,
filename: str = CLASSIFICATION_FILENAME,
) -> tuple[ClassificationData | None, list[str], list[str]]:
"""Load ``.repo-classification.yaml`` from a repo root and validate it.
Returns ``(data, errors, warnings)``. *data* is ``None`` when the file is
missing, unreadable, or has blocking validation errors.
"""
root = Path(repo_path)
doc = load_classification_document(root / filename)
block = extract_classification_block(doc)
if block is None:
if doc is None:
return (None, [f"{filename} missing or unreadable"], [])
return (None, [f"{filename} has no repo_classification block"], [])
errors, warnings = validate_classification(block)
if errors:
return (None, errors, warnings)
return (ClassificationData.from_block(block), [], warnings)

View File

@@ -4,6 +4,8 @@ from api.models.domain_goal import DomainGoal, DomainGoalStatus
from api.models.topic import Topic, TopicStatus
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority
@@ -39,6 +41,8 @@ __all__ = [
"Topic", "TopicStatus",
"ManagedRepo",
"RepoGoal", "RepoGoalStatus",
"Workplan",
"WorkplanDependency",
"Workstream",
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
@@ -61,4 +65,4 @@ __all__ = [
"WorkplanLaunchRequest",
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
]
]

View File

@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
nullable=False,
index=True,
)
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"),
ForeignKey("workplans.id", ondelete="SET NULL"),
nullable=True,
)
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
nullable=True,
index=True,
)
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"),
ForeignKey("workplans.id", ondelete="SET NULL"),
nullable=True,
)
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)

View File

@@ -47,8 +47,8 @@ class Contribution(Base, TimestampMixin):
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
@@ -62,5 +62,5 @@ class Contribution(Base, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821

View File

@@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin):
__tablename__ = "decisions"
__table_args__ = (
CheckConstraint(
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
name="ck_decisions_topic_or_workstream",
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
name="ck_decisions_topic_or_workplan",
),
)
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -57,7 +57,7 @@ class Decision(Base, TimestampMixin):
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="decision", lazy="selectin"
)

View File

@@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin):
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
@property
def domain_slug(self) -> str:

View File

@@ -1,8 +1,8 @@
import uuid
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
DateTime(timezone=True), nullable=True
)
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
classified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="repos", lazy="selectin"
)

View File

@@ -19,8 +19,8 @@ class ProgressEvent(Base):
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
@@ -38,6 +38,6 @@ class ProgressEvent(Base):
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821

View File

@@ -40,8 +40,8 @@ class RepoGoal(Base, TimestampMixin):
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
"DomainGoal", back_populates="repo_goals", lazy="selectin"
)
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
"Workstream", back_populates="repo_goal", lazy="selectin"
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
"Workplan", back_populates="repo_goal", lazy="selectin"
)
@property

View File

@@ -30,8 +30,8 @@ class Task(Base, TimestampMixin):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
workstream_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -50,7 +50,7 @@ class Task(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
subtasks: Mapped[list["Task"]] = relationship(
"Task", foreign_keys=[parent_task_id], lazy="selectin"
)

View File

@@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin):
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
notes: Mapped[list["TDNote"]] = relationship(
"TDNote", back_populates="td", lazy="selectin",
order_by="TDNote.created_at",

View File

@@ -27,8 +27,8 @@ class TokenEvent(Base):
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
@@ -75,5 +75,5 @@ class TokenEvent(Base):
)
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821

View File

@@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin):
domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="topics", lazy="selectin"
)
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
"Workstream", back_populates="topic", lazy="selectin"
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
"Workplan", back_populates="topic", lazy="selectin"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="topic", lazy="selectin"

70
api/models/workplan.py Normal file
View File

@@ -0,0 +1,70 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class Workplan(Base, TimestampMixin):
__tablename__ = "workplans"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
launch_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
concurrency_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="sequential", server_default="sequential", index=True
)
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repo_goals.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
"Task", back_populates="workplan", lazy="selectin"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="workplan", lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="workplan", lazy="selectin"
)
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
"WorkplanLaunchRequest", back_populates="workplan", lazy="selectin"
)

View File

@@ -0,0 +1,75 @@
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class WorkplanDependency(Base, TimestampMixin):
"""Directed dependency edge: `from_workplan` depends on a workplan or task.
Semantics: the target must reach a satisfactory state before `from_workplan`
can fully proceed. Hard deletes are intentional —
removing an edge removes a constraint, not information.
"""
__tablename__ = "workplan_dependencies"
__table_args__ = (
CheckConstraint(
"(to_workplan_id IS NOT NULL AND to_task_id IS NULL) "
"OR (to_workplan_id IS NULL AND to_task_id IS NOT NULL)",
name="ck_wp_dep_exactly_one_target",
),
Index(
"uq_wp_dep_workplan_target",
"from_workplan_id",
"to_workplan_id",
"relationship_type",
unique=True,
postgresql_where=text("to_workplan_id IS NOT NULL"),
),
Index(
"uq_wp_dep_task_target",
"from_workplan_id",
"to_task_id",
"relationship_type",
unique=True,
postgresql_where=text("to_task_id IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
relationship_type: Mapped[str] = mapped_column(
String(40), nullable=False, default="blocks", server_default="blocks", index=True
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
from_workplan: Mapped["Workplan"] = relationship( # noqa: F821
"Workplan", foreign_keys=[from_workplan_id]
)
to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821
"Workplan", foreign_keys=[to_workplan_id]
)
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821

View File

@@ -13,9 +13,9 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
workstream_id: Mapped[uuid.UUID] = mapped_column(
workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821

View File

@@ -1,70 +1,6 @@
import uuid
from datetime import date, datetime
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
from api.models.workplan import Workplan
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
Workstream = Workplan
from api.models.base import Base, TimestampMixin, new_uuid
class Workstream(Base, TimestampMixin):
__tablename__ = "workstreams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
launch_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
concurrency_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="sequential", server_default="sequential", index=True
)
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repo_goals.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
"Task", back_populates="workstream", lazy="selectin"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="workstream", lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="workstream", lazy="selectin"
)
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
"WorkplanLaunchRequest", back_populates="workstream", lazy="selectin"
)
__all__ = ["Workstream", "Workplan"]

View File

@@ -1,75 +1,6 @@
import uuid
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
from api.models.workplan_dependency import WorkplanDependency
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
WorkstreamDependency = WorkplanDependency
from api.models.base import Base, TimestampMixin, new_uuid
class WorkstreamDependency(Base, TimestampMixin):
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
Semantics: the target must reach a satisfactory state before `from_workstream`
can fully proceed. Hard deletes are intentional —
removing an edge removes a constraint, not information.
"""
__tablename__ = "workstream_dependencies"
__table_args__ = (
CheckConstraint(
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
name="ck_ws_dep_exactly_one_target",
),
Index(
"uq_ws_dep_workstream_target",
"from_workstream_id",
"to_workstream_id",
"relationship_type",
unique=True,
postgresql_where=text("to_workstream_id IS NOT NULL"),
),
Index(
"uq_ws_dep_task_target",
"from_workstream_id",
"to_task_id",
"relationship_type",
unique=True,
postgresql_where=text("to_task_id IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
relationship_type: Mapped[str] = mapped_column(
String(40), nullable=False, default="blocks", server_default="blocks", index=True
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
"Workstream", foreign_keys=[from_workstream_id]
)
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
"Workstream", foreign_keys=[to_workstream_id]
)
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
__all__ = ["WorkstreamDependency", "WorkplanDependency"]

View File

@@ -68,7 +68,7 @@ async def create_request(
priority=body.priority,
requesting_domain_id=req_domain.id,
requesting_agent=body.requesting_agent,
requesting_workstream_id=body.requesting_workstream_id,
requesting_workplan_id=body.requesting_workplan_id,
blocking_task_id=body.blocking_task_id,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
@@ -115,7 +115,7 @@ async def accept_request(
now = datetime.now(tz=timezone.utc)
req.status = "accepted"
req.fulfilling_agent = body.fulfilling_agent
req.fulfilling_workstream_id = body.fulfilling_workstream_id
req.fulfilling_workplan_id = body.fulfilling_workplan_id
req.accepted_at = now
# If no fulfilling domain was set by routing, infer from the accepting agent's context
@@ -212,7 +212,7 @@ async def patch_request(
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
priority, blocking_task_id, fulfilling_workstream_id.
priority, blocking_task_id, fulfilling_workplan_id.
Only fields present in the request body (non-None) are updated.
"""
req = await _get_request_or_404(request_id, session)
@@ -241,9 +241,9 @@ async def patch_request(
req.blocking_task_id = body.blocking_task_id
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
if body.fulfilling_workstream_id is not None:
req.fulfilling_workstream_id = body.fulfilling_workstream_id
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
if body.fulfilling_workplan_id is not None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
if not corrections:
return req # no-op

View File

@@ -43,7 +43,7 @@ async def create_contribution(
title=body.title,
body_path=body.body_path,
related_topic_id=body.related_topic_id,
related_workstream_id=body.related_workstream_id,
related_workplan_id=body.related_workplan_id,
notes=body.notes,
status=ContributionStatus.draft,
)

View File

@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
@router.get("/", response_model=list[DecisionRead])
async def list_decisions(
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: DecisionStatus | None = None,
decision_type: DecisionType | None = None,
@@ -48,8 +49,9 @@ async def list_decisions(
q = select(Decision)
if topic_id:
q = q.where(Decision.topic_id == topic_id)
if workstream_id:
q = q.where(Decision.workstream_id == workstream_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Decision.workplan_id == scope_id)
if status:
q = q.where(Decision.status == status)
if decision_type:
@@ -139,7 +141,7 @@ async def resolve_decision_action(
event = ProgressEvent(
topic_id=decision.topic_id,
workstream_id=decision.workstream_id,
workplan_id=decision.workplan_id,
decision_id=decision.id,
event_type="decision_resolved",
summary=f"Decision resolved: {decision.title}",
@@ -159,7 +161,7 @@ async def resolve_decision_action(
"decision_id": str(decision.id),
"title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None,
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
"decided_by": body.decided_by,
"rationale_snippet": (body.rationale or "")[:240],
},

View File

@@ -8,7 +8,7 @@ from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.domain import (
DomainCreate,
DomainDetail,
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
workstream_count = 0
if topic_ids:
workstream_count_row = await session.execute(
select(func.count()).select_from(Workstream)
.where(Workstream.topic_id.in_(topic_ids))
.where(Workstream.status == "active")
select(func.count()).select_from(Workplan)
.where(Workplan.topic_id.in_(topic_ids))
.where(Workplan.status == "active")
)
workstream_count = workstream_count_row.scalar_one()

View File

@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task, TaskStatus
from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.execution import (
ExecutionIntentRead,
ExecutionIntentUpdate,
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
STATE_HUB_RESPONSIBILITIES,
execution_state_for_launch,
queue_sort_key,
workstream_blockers,
workplan_blockers,
)
from api.routers.workstreams import _legacy_key, _meter_legacy_route
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
router = APIRouter(prefix="/execution", tags=["execution"])
@@ -50,7 +50,7 @@ async def _update_execution_intent(
body: ExecutionIntentUpdate,
session: AsyncSession,
) -> ExecutionIntentRead:
ws = await session.get(Workstream, workstream_id)
ws = await session.get(Workplan, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workplan not found")
@@ -94,22 +94,22 @@ async def workplan_stack(
include_blocked: bool = Query(True),
session: AsyncSession = Depends(get_session),
) -> list[WorkplanQueueItem]:
result = await session.execute(select(Workstream))
result = await session.execute(select(Workplan))
workstreams = [
ws for ws in result.scalars().all()
if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES
if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES
]
ws_by_id = {ws.id: ws for ws in workstreams}
ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams}
ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams}
dep_result = await session.execute(select(WorkstreamDependency))
dep_result = await session.execute(select(WorkplanDependency))
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
for dep in dep_result.scalars().all():
if dep.to_workstream_id is not None:
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
if dep.to_workplan_id is not None:
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
if dep.to_task_id is not None:
task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id)
task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id)
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
task_status: dict[uuid.UUID, str] = {}
@@ -121,9 +121,9 @@ async def workplan_stack(
for ws in workstreams:
if not include_manual and ws.execution_state == "manual":
continue
lifecycle_status = normalize_workstream_status(ws.status)
lifecycle_status = normalize_workplan_status(ws.status)
blocked_ws = [
blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status)
blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status)
if blocker in ws_by_id or blocker in ws_status
]
blocked_tasks = [
@@ -135,7 +135,7 @@ async def workplan_stack(
continue
sort_key = queue_sort_key(ws, eligible=eligible)
items.append(WorkplanQueueItem(
workstream_id=ws.id,
workplan_id=ws.id,
slug=ws.slug,
title=ws.title,
status=lifecycle_status,
@@ -149,7 +149,7 @@ async def workplan_stack(
execution_group=ws.execution_group,
scheduled_for=ws.scheduled_for,
eligible=eligible,
blocked_by_workstream_ids=blocked_ws,
blocked_by_workplan_ids=blocked_ws,
blocked_by_task_ids=blocked_tasks,
sort_key=sort_key,
))
@@ -165,12 +165,12 @@ async def create_launch_request(
body: LaunchRequestCreate,
session: AsyncSession = Depends(get_session),
) -> WorkplanLaunchRequest:
ws = await session.get(Workstream, body.workstream_id)
ws = await session.get(Workplan, body.workplan_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
raise HTTPException(status_code=404, detail="Workplan not found")
launch_request = WorkplanLaunchRequest(
workstream_id=ws.id,
workplan_id=ws.id,
requested_by=body.requested_by,
requested_actor=body.requested_actor,
launch_mode=body.launch_mode,
@@ -199,16 +199,16 @@ async def list_launch_requests(
) -> list[WorkplanLaunchRequest]:
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
if workstream_id:
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
if request_status:
q = q.where(WorkplanLaunchRequest.status == request_status)
result = await session.execute(q)
return list(result.scalars().all())
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
return ExecutionIntentRead(
workstream_id=ws.id,
workplan_id=ws.id,
execution_state=ws.execution_state,
launch_mode=ws.launch_mode,
concurrency_mode=ws.concurrency_mode,

View File

@@ -17,10 +17,10 @@ from api.flow_defs import (
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution
from api.models.task import Task, TaskStatus
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.services.lifecycle import transition_task_status, transition_workstream_status
from api.workplan_status import normalize_workstream_status
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.services.lifecycle import transition_task_status, transition_workplan_status
from api.workplan_status import normalize_workplan_status
router = APIRouter(prefix="/flows", tags=["flows"])
@@ -94,9 +94,9 @@ async def advance_workstation(
entity = await _entity(entity_type, entity_id, session)
if entity_type == "workstream":
transition_workstream_status(entity, target_workstation)
transition_workplan_status(entity, target_workstation)
elif entity_type == "task":
parent = await session.get(Workstream, entity.workstream_id)
parent = await session.get(Workplan, entity.workplan_id)
transition_task_status(
entity,
target_workstation,
@@ -117,7 +117,7 @@ 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
current_status = normalize_workplan_status(status) if entity_type == "workstream" else status
obj: dict[str, Any] = {
"id": str(entity.id),
"status": current_status,
@@ -127,21 +127,21 @@ async def _flow_object(
if entity_type == "workstream":
tasks = list((await session.execute(
select(Task).where(Task.workstream_id == entity_id)
select(Task).where(Task.workplan_id == entity_id)
)).scalars().all())
deps = list((await session.execute(
select(WorkstreamDependency).where(
WorkstreamDependency.from_workstream_id == entity_id
select(WorkplanDependency).where(
WorkplanDependency.from_workplan_id == entity_id
)
)).scalars().all())
dependency_ids = [dep.to_workstream_id for dep in deps]
dependency_ids = [dep.to_workplan_id for dep in deps]
dependency_workstations: list[dict[str, Any]] = []
if dependency_ids:
dep_ws = list((await session.execute(
select(Workstream).where(Workstream.id.in_(dependency_ids))
select(Workplan).where(Workplan.id.in_(dependency_ids))
)).scalars().all())
dependency_workstations = [
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
{"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)}
for ws in dep_ws
]
obj.update({
@@ -163,7 +163,7 @@ async def _entity(
session: AsyncSession,
):
model_by_type = {
"workstream": Workstream,
"workstream": Workplan,
"task": Task,
"contribution": Contribution,
"capability_request": CapabilityRequest,

View File

@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.task import TaskStatus
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import (
should_activate_parent_for_task_start,
status_value,
transition_task_status,
transition_workstream_status,
transition_workplan_status,
)
from api.task_status import TERMINAL_TASK_STATUSES
from api.services.reconciliation import (
ReconciliationClass,
StateChangeClassification,
classify_task_status_change,
classify_workstream_status_change,
classify_workplan_status_change,
)
from api.services.workplan_files import (
find_workplan_for_workstream,
find_workplan_for_workplan,
patch_task_status,
patch_workplan_status,
resolve_repo_path,
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
task_block_linked,
workplan_status,
)
from api.workplan_status import normalize_workstream_status
from api.workplan_status import normalize_workplan_status
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
@@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
statuses = [status_value(row[0]) for row in result.all()]
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
@@ -98,13 +98,13 @@ async def classify_state_change(
session: AsyncSession = Depends(get_session),
) -> StateChangeResponse:
if body.target_type == "workstream":
ws = await session.get(Workstream, body.target_id)
ws = await session.get(Workplan, body.target_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
raise HTTPException(status_code=404, detail="Workplan not found")
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
repo_path = resolve_repo_path(repo)
workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
actual_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = (
@@ -122,9 +122,9 @@ async def classify_state_change(
if body.tasks_terminal is not None
else await _workstream_tasks_terminal(session, ws.id)
)
current_status = normalize_workstream_status(ws.status)
target_status = normalize_workstream_status(body.target_status)
classification = classify_workstream_status_change(
current_status = normalize_workplan_status(ws.status)
target_status = normalize_workplan_status(body.target_status)
classification = classify_workplan_status_change(
current_status=current_status,
target_status=target_status,
file_backed=file_backed,
@@ -136,7 +136,7 @@ async def classify_state_change(
conflict = False
if body.apply:
expected_status = (
normalize_workstream_status(body.expected_current_status)
normalize_workplan_status(body.expected_current_status)
if body.expected_current_status is not None
else None
)
@@ -153,7 +153,7 @@ async def classify_state_change(
)
conflict = True
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
file_status = normalize_workstream_status(workplan_status(workplan_ref.path))
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
if file_status and file_status != current_status:
classification = _conflict(
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
@@ -163,7 +163,7 @@ async def classify_state_change(
else:
try:
patch_workplan_status(workplan_ref.path, target_status)
patched_status = normalize_workstream_status(workplan_status(workplan_ref.path))
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
except OSError as exc:
classification = _conflict(
f"workplan file write failed: {exc}",
@@ -178,7 +178,7 @@ async def classify_state_change(
)
conflict = True
else:
transition_workstream_status(ws, target_status)
transition_workplan_status(ws, target_status)
await session.commit()
write_result = "applied"
@@ -221,10 +221,10 @@ async def classify_state_change(
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
repo_path = resolve_repo_path(repo)
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
actual_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = (
@@ -291,7 +291,7 @@ async def classify_state_change(
parent_will_activate = should_activate_parent_for_task_start(
previous_task_status=current_status,
new_task_status=target_status,
parent_workstream_status=ws.status if ws else None,
parent_workplan_status=ws.status if ws else None,
)
try:
original_text = workplan_ref.path.read_text(encoding="utf-8")
@@ -299,7 +299,7 @@ async def classify_state_change(
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
if parent_will_activate:
patch_workplan_status(workplan_ref.path, "active")
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
if parent_status != "active":
if original_text is not None:
workplan_ref.path.write_text(original_text, encoding="utf-8")

View File

@@ -9,9 +9,10 @@ import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import case, func, select
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.config import settings
from api.database import get_session
@@ -29,11 +30,11 @@ from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal
from api.models.tpsc import TPSCSnapshot
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
from api.schemas.managed_repo import (
DispatchTask,
DispatchWorkstream,
DispatchWorkplan,
PendingInterfaceChange,
RepoCreate,
RepoDispatch,
@@ -44,6 +45,8 @@ from api.schemas.managed_repo import (
RepoScopeHealth,
RepoUpdate,
ScopeIssueDetail,
classification_fields_set,
validate_repo_classification_fields,
)
from hub_core.routers.repos import create_repos_router
@@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter:
repo_read_schema=RepoRead,
repo_path_register_schema=RepoPathRegister,
list_noload_fields=("goals",),
create_extension_fields=("topic_id",),
create_extension_fields=(
"topic_id",
"category",
"secondary_domains",
"capability_tags",
"business_stake",
"business_mechanics",
"classified_at",
"classified_by",
"standard_version",
),
after_register=_publish_repo_registered,
**route_flags,
)
router.include_router(_core_repo_router(include_slug_routes=False))
router.include_router(
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
)
@router.get("/", response_model=list[RepoRead])
async def list_repos(
response: Response,
domain: str | None = None,
category: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]:
"""List repos with optional domain and classification filters."""
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = (
select(ManagedRepo)
.options(noload(ManagedRepo.goals))
.order_by(ManagedRepo.name)
)
if domain:
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
q = q.where(
or_(
ManagedRepo.domain_id == domain_obj.id,
ManagedRepo.secondary_domains.contains([domain]),
)
)
if category:
q = q.where(ManagedRepo.category == category)
if capability_tag:
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
payload = body.model_dump()
validate_repo_classification_fields(
domain_slug=body.domain_slug,
fields=payload,
require_complete=classification_fields_set(payload),
)
repo = ManagedRepo(
domain_id=domain_obj.id,
slug=body.slug,
name=body.name,
local_path=body.local_path,
host_paths=body.host_paths,
remote_url=body.remote_url,
git_fingerprint=body.git_fingerprint,
description=body.description,
topic_id=body.topic_id,
category=body.category,
secondary_domains=body.secondary_domains,
capability_tags=body.capability_tags,
business_stake=body.business_stake,
business_mechanics=body.business_mechanics,
classified_at=body.classified_at,
classified_by=body.classified_by,
standard_version=body.standard_version,
)
session.add(repo)
await session.commit()
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return repo
@router.post("/onboard", response_model=RepoOnboardResult)
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
return entries
@router.patch("/{slug}", response_model=RepoRead)
async def update_repo_with_classification(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
"""Patch repo metadata including classification spine fields."""
repo = await _get_repo_by_slug(slug, session)
payload = body.model_dump(exclude_unset=True)
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
domain_slug = domain_obj.slug if domain_obj else ""
if classification_fields_set(payload):
merged = {
"category": payload.get("category", repo.category),
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
"capability_tags": payload.get("capability_tags", repo.capability_tags),
"business_stake": payload.get("business_stake", repo.business_stake),
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
}
validate_repo_classification_fields(
domain_slug=domain_slug,
fields=merged,
require_complete=True,
)
for field, value in payload.items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
router.include_router(
_core_repo_router(
include_collection_routes=False,
@@ -480,19 +609,19 @@ async def get_repo_dispatch(
# Active workstreams
ws_result = await session.execute(
select(Workstream)
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
.order_by(Workstream.created_at)
select(Workplan)
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
.order_by(Workplan.created_at)
)
workstreams = list(ws_result.scalars().all())
dispatch_workstreams: list[DispatchWorkstream] = []
dispatch_workstreams: list[DispatchWorkplan] = []
all_interventions: list[DispatchTask] = []
for ws in workstreams:
task_result = await session.execute(
select(Task)
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
.where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"]))
.order_by(Task.created_at)
)
tasks = list(task_result.scalars().all())
@@ -511,7 +640,7 @@ async def get_repo_dispatch(
all_interventions.extend(interventions)
dispatch_workstreams.append(
DispatchWorkstream(
DispatchWorkplan(
id=ws.id,
title=ws.title,
status=ws.status,
@@ -554,7 +683,7 @@ async def get_repo_dispatch(
return RepoDispatch(
repo_slug=slug,
active_goal=active_goal,
active_workstreams=dispatch_workstreams,
active_workplans=dispatch_workstreams,
human_interventions=all_interventions,
pending_interface_changes=pending_changes,
scope_needs_review=scope_needs_review,

View File

@@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
@@ -45,9 +45,9 @@ from api.schemas.workstream_dependency import WorkstreamDepStub
from api.routers.workstreams import _workplan_index
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES,
OPEN_WORKSTREAM_STATUSES,
normalize_workstream_status,
CLOSED_WORKPLAN_STATUSES,
OPEN_WORKPLAN_STATUSES,
normalize_workplan_status,
)
from task_flow_engine import FlowEngine
@@ -82,7 +82,7 @@ async def get_summary(
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.workplans),
noload(Topic.decisions),
noload(Topic.progress_events),
)
@@ -96,16 +96,16 @@ async def get_summary(
if topic_ids:
topic_ws_rows = await session.execute(
select(
Workstream.topic_id,
Workstream.id,
Workstream.slug,
Workstream.title,
Workstream.status,
Workstream.owner,
Workstream.due_date,
Workplan.topic_id,
Workplan.id,
Workplan.slug,
Workplan.title,
Workplan.status,
Workplan.owner,
Workplan.due_date,
)
.where(Workstream.topic_id.in_(topic_ids))
.order_by(Workstream.created_at)
.where(Workplan.topic_id.in_(topic_ids))
.order_by(Workplan.created_at)
)
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
topic_workstreams.setdefault(topic_id, []).append({
@@ -136,10 +136,10 @@ async def get_summary(
recent = list(recent_rows.scalars().all())
open_ws_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -147,7 +147,7 @@ async def get_summary(
task_per_ws: dict = {}
task_statuses_per_ws: dict = {}
for ws_id, tstat, cnt in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
):
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
@@ -157,9 +157,9 @@ async def get_summary(
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -168,16 +168,16 @@ async def get_summary(
dep_ws_ids = set()
dep_task_ids = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
dep_ws_ids.add(d.from_workplan_id)
if d.to_workplan_id:
dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
@@ -189,7 +189,7 @@ async def get_summary(
# Index: workstream_id → (depends_on stubs, blocks stubs)
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
@@ -230,9 +230,9 @@ async def get_summary(
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_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
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
@@ -246,7 +246,7 @@ async def get_summary(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
select(Workplan.status, func.count()).group_by(Workplan.status)
)}
task_counts = {r[0]: r[1] for r in await session.execute(
select(Task.status, func.count()).group_by(Task.status)
@@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.workplans),
noload(Topic.decisions),
noload(Topic.progress_events),
)
@@ -418,12 +418,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
topic_map = {topic.id: topic for topic in topics}
workstream_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
Workplan.planning_priority.asc().nullslast(),
Workplan.planning_order.asc().nullslast(),
Workplan.updated_at.desc(),
)
)
workstreams_all = list(workstream_rows.scalars().all())
@@ -455,7 +455,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
task_statuses_per_ws: dict = {}
task_totals_by_status: dict[str, int] = {}
for ws_id, task_status, count in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
):
status = status_value(task_status)
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
@@ -467,15 +467,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
open_ws = [
w for w in workstreams_all
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
]
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_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
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
topic_counts = {r[0]: r[1] for r in await session.execute(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
select(Workplan.status, func.count()).group_by(Workplan.status)
)}
dec_counts = {r[0]: r[1] for r in await session.execute(
select(Decision.status, func.count()).group_by(Decision.status)
@@ -631,7 +631,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
workplan_rows.append(DashboardWorkplanRow(
id=w.id,
title=w.title,
status=normalize_workstream_status(w.status),
status=normalize_workplan_status(w.status),
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"),
@@ -695,9 +695,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
# Active workstream counts per domain (join through topics)
ws_per_domain = {}
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.in_(["active", "blocked"]))
select(Topic.domain_id, func.count(Workplan.id))
.join(Workplan, Workplan.topic_id == Topic.id)
.where(Workplan.status.in_(["active", "blocked"]))
.group_by(Topic.domain_id)
):
ws_per_domain[domain_id] = cnt
@@ -734,10 +734,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
Used by workstreams.md and dependencies.md which only need dep edges.
"""
open_ws_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -745,9 +745,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -755,9 +755,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_ws_ids: set = set()
dep_task_ids: set = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
dep_ws_ids.add(d.from_workplan_id)
if d.to_workplan_id:
dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
@@ -765,7 +765,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
@@ -777,7 +777,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
@@ -831,7 +831,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
.options(noload("*"))
.where(Decision.status == DecisionStatus.resolved)
.where(Decision.decided_at >= cutoff)
.where(Decision.workstream_id.isnot(None))
.where(Decision.workplan_id.isnot(None))
.order_by(Decision.decided_at.desc())
.limit(20)
)
@@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
open_tasks_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id == decision.workstream_id)
.where(Task.workplan_id == decision.workplan_id)
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
)
open_tasks = list(open_tasks_rows.scalars().all())
@@ -848,7 +848,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
domain_slug = await _get_domain_slug_for_workstream(ws, session)
steps.append(NextStep(
type="resolved_decision",
@@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
# ── Signal 2: cleared dependencies ──────────────────────────────────────
all_dep_rows = await session.execute(
select(
WorkstreamDependency.from_workstream_id,
WorkstreamDependency.to_workstream_id,
).where(WorkstreamDependency.to_workstream_id.isnot(None))
WorkplanDependency.from_workplan_id,
WorkplanDependency.to_workplan_id,
).where(WorkplanDependency.to_workplan_id.isnot(None))
)
all_deps = all_dep_rows.all()
# Group from_workstream_id → set of to_workstream_ids
# Group from_workplan_id → set of to_workplan_ids
dep_map: dict = {}
dep_ws_ids = set()
for from_ws_id, to_ws_id in all_deps:
@@ -886,12 +886,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
if dep_ws_ids:
ws_rows = await session.execute(
select(
Workstream.id,
Workstream.status,
Workstream.title,
Workstream.slug,
Workstream.topic_id,
).where(Workstream.id.in_(dep_ws_ids))
Workplan.id,
Workplan.status,
Workplan.title,
Workplan.slug,
Workplan.topic_id,
).where(Workplan.id.in_(dep_ws_ids))
)
ws_info = {
ws_id: {
@@ -906,9 +906,9 @@ 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 normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
and all(
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
for to_id in to_ws_ids
)
]
@@ -918,11 +918,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
todo_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id.in_(ready_from_ws_ids))
.where(Task.workplan_id.in_(ready_from_ws_ids))
.where(Task.status == TaskStatus.todo)
)
for task in todo_rows.scalars().all():
todo_by_ws.setdefault(task.workstream_id, []).append(task)
todo_by_ws.setdefault(task.workplan_id, []).append(task)
for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {})
@@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
return steps
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
"""Get the domain slug for a workstream via its topic."""
if ws is None or ws.topic_id is None:
return None

View File

@@ -9,7 +9,7 @@ from api.database import get_session
from api.models.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.task import (
TaskCountRead,
TaskCreate,
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead])
async def list_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
assignee: str | None = None,
@@ -37,8 +38,9 @@ async def list_tasks(
session: AsyncSession = Depends(get_session),
) -> list[Task]:
q = select(Task)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
if assignee:
@@ -60,18 +62,20 @@ async def list_tasks(
@router.get("/counts", response_model=list[TaskCountRead])
async def count_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TaskCountRead]:
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
rows = await session.execute(q)
return [
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
TaskCountRead(workplan_id=ws_id, status=task_status, count=count)
for ws_id, task_status, count in rows
]
@@ -84,7 +88,7 @@ async def create_task(
task = Task(**body.model_dump())
session.add(task)
if status_value(task.status) == "progress":
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
task.status,
@@ -137,7 +141,7 @@ async def bulk_status_sync(
target_status = status_value(update.status)
if update.blocking_reason is not None:
task.blocking_reason = update.blocking_reason
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
update.status,
@@ -146,7 +150,7 @@ async def bulk_status_sync(
)
event = ProgressEvent(
task_id=task.id,
workstream_id=task.workstream_id,
workplan_id=task.workplan_id,
event_type="task_status_changed",
summary=f"Task status -> {target_status}: {task.title}",
author=author,
@@ -218,7 +222,7 @@ async def update_task(
for field, value in update_data.items():
setattr(task, field, value)
if new_status is not None:
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
status_update,
@@ -247,7 +251,7 @@ async def update_task(
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
# Tier 2: prorate workplan total across task count
count_result = await session.execute(
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id)
)
task_count = max(count_result.scalar() or 1, 1)
tin = token_data["workplan_tokens_in"] // task_count
@@ -273,12 +277,12 @@ async def update_task(
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
# Resolve repo_id via workstream
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
repo_id = ws.repo_id if ws else None
event = TokenEvent(
task_id=task_id,
workstream_id=task.workstream_id,
workplan_id=task.workplan_id,
repo_id=repo_id,
tokens_in=tin,
tokens_out=tout,

View File

@@ -11,7 +11,7 @@ from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.token_event import (
RepoTokenSummary,
TokenAggregateRow,
@@ -102,14 +102,14 @@ def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]:
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
# Auto-populate workstream_id from task if not provided
if data.get("task_id") and not data.get("workstream_id"):
if data.get("task_id") and not data.get("workplan_id"):
task = await session.get(Task, data["task_id"])
if task:
data["workstream_id"] = task.workstream_id
data["workplan_id"] = task.workplan_id
# Auto-populate repo_id from workstream if not provided
if data.get("workstream_id") and not data.get("repo_id"):
ws = await session.get(Workstream, data["workstream_id"])
if data.get("workplan_id") and not data.get("repo_id"):
ws = await session.get(Workplan, data["workplan_id"])
if ws and ws.repo_id:
data["repo_id"] = ws.repo_id
return data
@@ -169,7 +169,7 @@ def _filter_query(
if task_id:
q = q.where(TokenEvent.task_id == task_id)
if workstream_id:
q = q.where(TokenEvent.workstream_id == workstream_id)
q = q.where(TokenEvent.workplan_id == workstream_id)
if repo_id:
q = q.where(TokenEvent.repo_id == repo_id)
if ref_type:
@@ -195,7 +195,7 @@ def _filter_query(
if unattributed:
q = q.where(
TokenEvent.repo_id.is_(None),
TokenEvent.workstream_id.is_(None),
TokenEvent.workplan_id.is_(None),
TokenEvent.task_id.is_(None),
)
return q
@@ -238,7 +238,7 @@ async def get_token_summary(
uid = uuid.UUID(id)
except ValueError:
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
q = q.where(TokenEvent.workstream_id == uid)
q = q.where(TokenEvent.workplan_id == uid)
elif scope == "repo":
try:
uid = uuid.UUID(id)
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
Resolution order for each event:
1. token_events.repo_id (direct)
2. → workstreams.repo_id (via workstream_id)
3. → task.workstream_id → workstreams.repo_id (via task_id)
3. → task.workplan_id → workstreams.repo_id (via task_id)
Only events that resolve to a repo are included.
"""
@@ -314,8 +314,8 @@ async def get_tokens_by_repo(
)
events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
ws_result = await session.execute(select(Workplan))
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
@@ -326,9 +326,9 @@ async def get_tokens_by_repo(
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id:
return e.repo_id
ws_id = e.workstream_id
ws_id = e.workplan_id
if not ws_id and e.task_id and e.task_id in task_map:
ws_id = task_map[e.task_id].workstream_id
ws_id = task_map[e.task_id].workplan_id
if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id
return None
@@ -391,8 +391,8 @@ async def get_token_aggregate(
)
events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
ws_result = await session.execute(select(Workplan))
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
@@ -403,9 +403,9 @@ async def get_token_aggregate(
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id:
return e.repo_id
ws_id = e.workstream_id
ws_id = e.workplan_id
if not ws_id and e.task_id and e.task_id in task_map:
ws_id = task_map[e.task_id].workstream_id
ws_id = task_map[e.task_id].workplan_id
if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id
return None
@@ -458,7 +458,7 @@ async def get_token_aggregate(
repo = repo_map.get(rid) if rid else None
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
ws_id = e.workstream_id or (task_map[e.task_id].workstream_id if e.task_id in task_map else None)
ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None)
ws = ws_map.get(ws_id) if ws_id else None
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
@@ -520,7 +520,7 @@ async def get_token_quality(
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
if e.source_provider == "task_fallback" or e.note == "heuristic":
fallback_count += 1
if e.measurement_kind == "measured" and not (e.repo_id or e.workstream_id or e.task_id):
if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id):
unattributed_measured_count += 1
if e.measurement_kind == "measured" and not e.source_id:
missing_provenance_count += 1

View File

@@ -30,7 +30,7 @@ async def list_topics(
) -> list[Topic]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Topic).options(
noload(Topic.workstreams),
noload(Topic.workplans),
noload(Topic.decisions),
noload(Topic.progress_events),
)

View File

@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
from api.routers.workstreams import _legacy_key, _meter_legacy_route
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
async def _create_dependency(
*,
workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate,
workplan_id: uuid.UUID,
body: WorkplanDependencyCreate,
session: AsyncSession,
) -> WorkstreamDependency:
if await session.get(Workstream, workstream_id) is None:
) -> WorkplanDependency:
if await session.get(Workplan, workplan_id) is None:
raise HTTPException(status_code=404, detail="from workplan not found")
has_workstream_target = body.to_workstream_id is not None
has_workplan_target = body.to_workplan_id is not None
has_task_target = body.to_task_id is not None
if has_workstream_target == has_task_target:
if has_workplan_target == has_task_target:
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None:
raise HTTPException(status_code=404, detail="target workplan not found")
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
raise HTTPException(status_code=404, detail="target task not found")
if workstream_id == body.to_workstream_id:
if workplan_id == body.to_workplan_id:
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
dep = WorkstreamDependency(
from_workstream_id=workstream_id,
to_workstream_id=body.to_workstream_id,
dep = WorkplanDependency(
from_workplan_id=workplan_id,
to_workplan_id=body.to_workplan_id,
to_task_id=body.to_task_id,
relationship_type=body.relationship_type,
description=body.description,
@@ -51,15 +51,15 @@ async def _create_dependency(
async def _list_dependencies(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
session: AsyncSession,
) -> list[WorkstreamDependency]:
if await session.get(Workstream, workstream_id) is None:
) -> list[WorkplanDependency]:
if await session.get(Workplan, workplan_id) is None:
raise HTTPException(status_code=404, detail="workplan not found")
rows = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id == workstream_id)
| (WorkstreamDependency.to_workstream_id == workstream_id)
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id == workplan_id)
| (WorkplanDependency.to_workplan_id == workplan_id)
)
)
return list(rows.scalars().all())
@@ -67,14 +67,14 @@ async def _list_dependencies(
async def _delete_dependency(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
dep_id: uuid.UUID,
session: AsyncSession,
) -> None:
dep = await session.get(WorkstreamDependency, dep_id)
dep = await session.get(WorkplanDependency, dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="dependency not found")
if dep.from_workstream_id != workstream_id:
if dep.from_workplan_id != workplan_id:
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
await session.delete(dep)
await session.commit()
@@ -82,17 +82,17 @@ async def _delete_dependency(
@router.post(
"/{workstream_id}/dependencies/",
response_model=WorkstreamDependencyRead,
response_model=WorkplanDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_dependency(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate,
body: WorkplanDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
"""Record that workstream_id depends on another workstream or a task."""
) -> WorkplanDependency:
"""Record that workstream_id depends on another workplan or a task."""
await _meter_legacy_route(
session=session,
request=request,
@@ -100,33 +100,33 @@ async def create_dependency(
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_id}/dependencies/",
)
return await _create_dependency(workstream_id=workstream_id, body=body, session=session)
return await _create_dependency(workplan_id=workstream_id, body=body, session=session)
@workplan_router.post(
"/{workplan_id}/dependencies/",
response_model=WorkstreamDependencyRead,
response_model=WorkplanDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_workplan_dependency(
workplan_id: uuid.UUID,
body: WorkstreamDependencyCreate,
body: WorkplanDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
) -> WorkplanDependency:
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
@router.get(
"/{workstream_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
response_model=list[WorkplanDependencyRead],
)
async def list_dependencies(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]:
"""Return all dependency edges touching this workstream (both directions)."""
) -> list[WorkplanDependency]:
"""Return all dependency edges touching this workplan (both directions)."""
await _meter_legacy_route(
session=session,
request=request,
@@ -134,18 +134,18 @@ async def list_dependencies(
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_id}/dependencies/",
)
return await _list_dependencies(workstream_id=workstream_id, session=session)
return await _list_dependencies(workplan_id=workstream_id, session=session)
@workplan_router.get(
"/{workplan_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
response_model=list[WorkplanDependencyRead],
)
async def list_workplan_dependencies(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]:
return await _list_dependencies(workstream_id=workplan_id, session=session)
) -> list[WorkplanDependency]:
return await _list_dependencies(workplan_id=workplan_id, session=session)
@router.delete(
@@ -167,7 +167,7 @@ async def delete_dependency(
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
)
await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session)
await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session)
@workplan_router.delete(
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> None:
await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session)
await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session)

View File

@@ -15,21 +15,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.managed_repo import ManagedRepo
from api.models.workstream import Workstream
from api.schemas.workstream import (
WorkstreamCreate,
WorkstreamRead,
WorkstreamUpdate,
from api.models.workplan import Workplan
from api.schemas.workplan import (
WorkplanCreate,
WorkplanRead,
WorkplanUpdate,
)
from api.services.lifecycle import transition_workstream_status
from api.services.lifecycle import transition_workplan_status
from api.services.legacy_meter import (
LegacyUsageIdentity,
identity_from_request,
record_legacy_usage,
)
from api.workplan_status import (
is_supported_workstream_status,
normalize_workstream_status,
is_supported_workplan_status,
normalize_workplan_status,
ready_review_status,
)
@@ -138,7 +138,7 @@ async def _meter_legacy_event(
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
async def _list_workstreams(
async def _list_workplans(
*,
topic_id: uuid.UUID | None,
repo_id: uuid.UUID | None,
@@ -147,27 +147,27 @@ async def _list_workstreams(
owner: str | None,
slug: str | None,
session: AsyncSession,
) -> list[Workstream]:
q = select(Workstream)
) -> list[Workplan]:
q = select(Workplan)
if topic_id:
q = q.where(Workstream.topic_id == topic_id)
q = q.where(Workplan.topic_id == topic_id)
if repo_id:
q = q.where(Workstream.repo_id == repo_id)
q = q.where(Workplan.repo_id == repo_id)
if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id)
q = q.where(Workplan.repo_goal_id == repo_goal_id)
if status_filter:
normalised_status = normalize_workstream_status(status_filter)
if not is_supported_workstream_status(status_filter):
normalised_status = normalize_workplan_status(status_filter)
if not is_supported_workplan_status(status_filter):
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
q = q.where(Workstream.status == normalised_status)
q = q.where(Workplan.status == normalised_status)
if owner:
q = q.where(Workstream.owner == owner)
q = q.where(Workplan.owner == owner)
if slug:
q = q.where(Workstream.slug == slug)
q = q.where(Workplan.slug == slug)
q = q.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
Workplan.planning_priority.asc().nullslast(),
Workplan.planning_order.asc().nullslast(),
Workplan.updated_at.desc(),
)
result = await session.execute(q)
return list(result.scalars().all())
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
continue
for path in sorted(directory.glob("*.md")):
data = _frontmatter(path)
workstream_id = data.get("state_hub_workstream_id")
if not workstream_id:
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
if not workplan_id:
continue
file_status = normalize_workstream_status(data.get("status", ""))
file_status = normalize_workplan_status(data.get("status", ""))
review = (
ready_review_status(
root,
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
if file_status == "ready"
else None
)
index[str(workstream_id)] = {
index[str(workplan_id)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
@@ -287,79 +287,79 @@ async def _workplan_index(
return _INDEX_CACHE
async def _create_workstream(
async def _create_workplan(
*,
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession,
) -> Workstream:
ws = Workstream(**body.model_dump())
session.add(ws)
) -> Workplan:
wp = Workplan(**body.model_dump())
session.add(wp)
await session.commit()
await session.refresh(ws)
return ws
await session.refresh(wp)
return wp
async def _get_workstream(
async def _get_workplan(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
return ws
return wp
async def _update_workstream(
async def _update_workplan(
*,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
workplan_id: uuid.UUID,
body: WorkplanUpdate,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
update_data = body.model_dump(exclude_unset=True)
status_update = update_data.pop("status", None)
prev_status = ws.status
prev_status = wp.status
for field, value in update_data.items():
setattr(ws, field, value)
setattr(wp, field, value)
if status_update is not None:
transition_workstream_status(ws, status_update)
transition_workplan_status(wp, status_update)
await session.commit()
await session.refresh(ws)
await session.refresh(wp)
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
await _publish_completion_events(ws, session)
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
await _publish_completion_events(wp, session)
return ws
return wp
async def _archive_workstream(
async def _archive_workplan(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
transition_workstream_status(ws, "archived")
transition_workplan_status(wp, "archived")
await session.commit()
await session.refresh(ws)
return ws
await session.refresh(wp)
return wp
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None:
workplan_envelope = EventEnvelope.new(
_COMPLETED_WORKPLAN_EVENT,
attributes={
"workplan_id": str(ws.id),
"legacy_workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
"workplan_id": str(wp.id),
"legacy_workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
legacy_envelope = EventEnvelope.new(
_COMPLETED_WORKSTREAM_EVENT,
attributes={
"workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
"workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
@router.get("/", response_model=list[WorkstreamRead])
@router.get("/", response_model=list[WorkplanRead])
async def list_workstreams(
request: Request,
response: Response,
@@ -394,7 +394,7 @@ async def list_workstreams(
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
) -> list[Workplan]:
await _meter_legacy_route(
session=session,
request=request,
@@ -402,7 +402,7 @@ async def list_workstreams(
interface_key=_legacy_key("GET", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _list_workstreams(
return await _list_workplans(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
@@ -413,7 +413,7 @@ async def list_workstreams(
)
@workplan_router.get("/", response_model=list[WorkstreamRead])
@workplan_router.get("/", response_model=list[WorkplanRead])
async def list_workplans(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
@@ -422,8 +422,8 @@ async def list_workplans(
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
return await _list_workstreams(
) -> list[Workplan]:
return await _list_workplans(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
return await _workplan_index(refresh=refresh, session=session)
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
async def create_workstream(
request: Request,
response: Response,
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -473,24 +473,24 @@ async def create_workstream(
interface_key=_legacy_key("POST", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _create_workstream(body=body, session=session)
return await _create_workplan(body=body, session=session)
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
async def create_workplan(
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _create_workstream(body=body, session=session)
) -> Workplan:
return await _create_workplan(body=body, session=session)
@router.get("/{workstream_id}", response_model=WorkstreamRead)
@router.get("/{workstream_id}", response_model=WorkplanRead)
async def get_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -498,25 +498,25 @@ async def get_workstream(
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _get_workstream(workstream_id=workstream_id, session=session)
return await _get_workplan(workplan_id=workstream_id, session=session)
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.get("/{workplan_id}", response_model=WorkplanRead)
async def get_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _get_workstream(workstream_id=workplan_id, session=session)
) -> Workplan:
return await _get_workplan(workplan_id=workplan_id, session=session)
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
@router.patch("/{workstream_id}", response_model=WorkplanRead)
async def update_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
body: WorkplanUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -524,25 +524,25 @@ async def update_workstream(
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
return await _update_workplan(workplan_id=workstream_id, body=body, session=session)
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
async def update_workplan(
workplan_id: uuid.UUID,
body: WorkstreamUpdate,
body: WorkplanUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
) -> Workplan:
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
@router.delete("/{workstream_id}", response_model=WorkplanRead)
async def archive_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -550,12 +550,12 @@ async def archive_workstream(
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _archive_workstream(workstream_id=workstream_id, session=session)
return await _archive_workplan(workplan_id=workstream_id, session=session)
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)
async def archive_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _archive_workstream(workstream_id=workplan_id, session=session)
) -> Workplan:
return await _archive_workplan(workplan_id=workplan_id, session=session)

View File

@@ -1,4 +1,5 @@
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
__all__ = [
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
"TaskCreate", "TaskUpdate", "TaskRead",
"DecisionCreate", "DecisionUpdate", "DecisionRead",

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
from hub_core.schemas.capability import (
CapabilityRequestDispute,
@@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel):
priority: str = "medium"
requesting_domain: str # slug, resolved to domain_id in router
requesting_agent: str
requesting_workstream_id: uuid.UUID | None = None
requesting_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"),
)
blocking_task_id: uuid.UUID | None = None
class CapabilityRequestAccept(BaseModel):
fulfilling_agent: str
fulfilling_workstream_id: uuid.UUID | None = None
fulfilling_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
)
class CapabilityRequestPatch(BaseModel):
catalog_entry_id: uuid.UUID | None = None
priority: str | None = None
blocking_task_id: uuid.UUID | None = None
fulfilling_workstream_id: uuid.UUID | None = None
fulfilling_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
)
class CapabilityRequestReroute(BaseModel):
@@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
status: str
requesting_domain_slug: str
requesting_agent: str
requesting_workstream_id: uuid.UUID | None = None
requesting_workplan_id: uuid.UUID | None = None
fulfilling_domain_slug: str | None = None
fulfilling_agent: str | None = None
fulfilling_workstream_id: uuid.UUID | None = None
fulfilling_workplan_id: uuid.UUID | None = None
blocking_task_id: uuid.UUID | None = None
catalog_entry_id: uuid.UUID | None = None
resolution_note: str | None = None
@@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
completed_at: datetime | None = None
created_at: datetime
updated_at: datetime
@computed_field # type: ignore[prop-decorator]
@property
def requesting_workstream_id(self) -> uuid.UUID | None:
return self.requesting_workplan_id
@computed_field # type: ignore[prop-decorator]
@property
def fulfilling_workstream_id(self) -> uuid.UUID | None:
return self.fulfilling_workplan_id

43
api/schemas/compat.py Normal file
View File

@@ -0,0 +1,43 @@
"""Shared Pydantic field helpers for workplan / workstream compatibility."""
from __future__ import annotations
import uuid
from pydantic import AliasChoices, Field, computed_field, model_validator
def workplan_id_field(*, default: uuid.UUID | None = None) -> uuid.UUID | None:
return Field(
default=default,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
class WorkplanIdCompatMixin:
"""Accept ``workplan_id`` or legacy ``workstream_id`` on input; emit both on output."""
workplan_id: uuid.UUID = workplan_id_field()
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID:
return self.workplan_id
class WorkplanIdCreateMixin:
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
@model_validator(mode="after")
def _require_workplan_id(self):
if self.workplan_id is None:
raise ValueError("workplan_id is required")
return self
class OptionalWorkplanIdCompatMixin:
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID | None:
return self.workplan_id

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
from api.models.contribution import ContributionStatus, ContributionType
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
title: str
body_path: str | None = None
related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None
related_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"),
)
repo_id: uuid.UUID | None = None
notes: str | None = None
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
status: ContributionStatus
body_path: str | None = None
related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None
related_workplan_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
submitted_at: datetime | None = None
resolved_at: datetime | None = None
notes: str | None = None
created_at: datetime
updated_at: datetime
@computed_field # type: ignore[prop-decorator]
@property
def related_workstream_id(self) -> uuid.UUID | None:
return self.related_workplan_id

View File

@@ -4,11 +4,16 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, model_validator
from api.models.decision import DecisionStatus, DecisionType
from api.schemas.compat import OptionalWorkplanIdCompatMixin
from pydantic import AliasChoices, Field
class DecisionCreate(BaseModel):
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
title: str
description: str | None = None
decision_type: DecisionType = DecisionType.pending
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
escalation_note: str | None = None
@model_validator(mode="after")
def topic_or_workstream_required(self) -> "DecisionCreate":
if self.topic_id is None and self.workstream_id is None:
raise ValueError("At least one of topic_id or workstream_id must be set")
def topic_or_workplan_required(self) -> "DecisionCreate":
if self.topic_id is None and self.workplan_id is None:
raise ValueError("At least one of topic_id or workplan_id must be set")
return self
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
superseded_by: uuid.UUID | None = None
class DecisionRead(BaseModel):
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
title: str
description: str | None = None
decision_type: DecisionType
@@ -61,4 +65,4 @@ class DecisionRead(BaseModel):
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
updated_at: datetime

View File

@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
@@ -21,7 +21,12 @@ class ExecutionIntentUpdate(BaseModel):
class ExecutionIntentRead(BaseModel):
workstream_id: uuid.UUID
workplan_id: uuid.UUID
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID:
return self.workplan_id
execution_state: ExecutionState
launch_mode: LaunchMode
concurrency_mode: ConcurrencyMode
@@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel):
class WorkplanQueueItem(BaseModel):
workstream_id: uuid.UUID
workplan_id: uuid.UUID
slug: str
title: str
status: str
@@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel):
execution_group: str | None = None
scheduled_for: datetime | None = None
eligible: bool
blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list)
blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list)
@computed_field # type: ignore[prop-decorator]
@property
def blocked_by_workstream_ids(self) -> list[uuid.UUID]:
return self.blocked_by_workplan_ids
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
sort_key: list[str | int] = Field(default_factory=list)
class LaunchRequestCreate(BaseModel):
workstream_id: uuid.UUID
workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id"))
requested_by: str = "dashboard"
requested_actor: str | None = None
launch_mode: LaunchMode = "queued"
@@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel):
class LaunchRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
workstream_id: uuid.UUID
workplan_id: uuid.UUID
requested_by: str
requested_actor: str | None = None
launch_mode: LaunchMode
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID:
return self.workplan_id
concurrency_mode: ConcurrencyMode
priority: str | None = None
repo_id: uuid.UUID | None = None

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
from api.models.extension_point import EPStatus
@@ -18,7 +18,10 @@ class EPCreate(BaseModel):
status: EPStatus = EPStatus.open
priority: str = "medium"
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
class EPUpdate(BaseModel):
@@ -29,7 +32,10 @@ class EPUpdate(BaseModel):
ep_type: str | None = None
status: EPStatus | None = None
priority: str | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
class EPRead(BaseModel):
@@ -45,6 +51,10 @@ class EPRead(BaseModel):
status: EPStatus
priority: str
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
created_at: datetime
@property
def workstream_id(self) -> uuid.UUID | None:
return self.workplan_id
updated_at: datetime

View File

@@ -2,8 +2,10 @@ import uuid
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from fastapi import HTTPException
from pydantic import BaseModel, Field, model_validator
from api.classification import validate_classification
from hub_core.schemas.managed_repo import (
RepoCreate as CoreRepoCreate,
RepoPathRegister,
@@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import (
)
class RepoCreate(CoreRepoCreate):
class ClassificationFields(BaseModel):
category: str | None = None
secondary_domains: list[str] | None = None
capability_tags: list[str] | None = None
business_stake: list[str] | None = None
business_mechanics: list[str] | None = None
classified_at: date | None = None
classified_by: str | None = None
standard_version: str | None = None
def classification_fields_set(data: dict[str, Any]) -> bool:
keys = (
"category",
"secondary_domains",
"capability_tags",
"business_stake",
"business_mechanics",
"classified_at",
"classified_by",
"standard_version",
)
return any(data.get(key) is not None for key in keys)
def validate_repo_classification_fields(
*,
domain_slug: str,
fields: dict[str, Any],
require_complete: bool = False,
) -> dict[str, Any]:
"""Validate classification fields and return normalized values for persistence."""
if not classification_fields_set(fields) and not require_complete:
return fields
block = {
"category": fields.get("category"),
"domain": domain_slug,
"secondary_domains": fields.get("secondary_domains") or [],
"capability_tags": fields.get("capability_tags") or [],
"business_stake": fields.get("business_stake") or [],
"business_mechanics": fields.get("business_mechanics") or [],
}
if require_complete or fields.get("category") is not None:
if block["category"] is None:
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
if classification_fields_set(fields) and block["category"] is not None:
errors, warnings = validate_classification(block)
if errors:
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
return fields
class RepoCreate(CoreRepoCreate, ClassificationFields):
topic_id: uuid.UUID | None = None
@model_validator(mode="after")
def validate_classification_on_create(self) -> "RepoCreate":
validate_repo_classification_fields(
domain_slug=self.domain_slug,
fields=self.model_dump(),
require_complete=classification_fields_set(self.model_dump()),
)
return self
class RepoUpdate(BaseModel):
class RepoUpdate(ClassificationFields):
name: str | None = None
local_path: str | None = None
remote_url: str | None = None
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
stderr: str = ""
class RepoRead(CoreRepoRead):
class RepoRead(CoreRepoRead, ClassificationFields):
topic_id: uuid.UUID | None = None
sbom_source: str | None = None
last_sbom_at: datetime | None = None
@@ -59,13 +123,17 @@ class DispatchTask(BaseModel):
needs_human: bool
class DispatchWorkstream(BaseModel):
class DispatchWorkplan(BaseModel):
id: uuid.UUID
title: str
status: str
pending_tasks: list[DispatchTask]
# Legacy alias
DispatchWorkstream = DispatchWorkplan
class PendingInterfaceChange(BaseModel):
id: uuid.UUID
title: str
@@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
class RepoDispatch(BaseModel):
repo_slug: str
active_goal: dict[str, Any] | None
active_workstreams: list[DispatchWorkstream]
active_workplans: list[DispatchWorkplan]
human_interventions: list[DispatchTask]
pending_interface_changes: list[PendingInterfaceChange]
scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail]
last_state_synced_at: datetime | None
@property
def active_workstreams(self) -> list[DispatchWorkplan]:
return self.active_workplans
class RepoScopeHealth(BaseModel):
repo_slug: str
@@ -104,4 +176,4 @@ class RepoScopeHealth(BaseModel):
local_path: str | None = None
path_available: bool
scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail]
scope_issue_details: list[ScopeIssueDetail]

View File

@@ -2,12 +2,17 @@ import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
from api.schemas.compat import OptionalWorkplanIdCompatMixin
class ProgressEventCreate(BaseModel):
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None
event_type: str
@@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel):
session_id: str | None = None
class ProgressEventRead(BaseModel):
class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None
event_type: str
@@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel):
detail: dict[str, Any] | None = None
author: str | None = None
session_id: str | None = None
created_at: datetime
created_at: datetime

View File

@@ -5,6 +5,7 @@ from typing import Self
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from api.models.task import TaskPriority, TaskStatus
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
from api.task_status import normalize_task_status
@@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel):
return normalize_task_status(value)
class TaskCreate(TaskStatusMixin):
workstream_id: uuid.UUID
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
@@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel):
return value
class TaskRead(TaskStatusMixin):
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
workstream_id: uuid.UUID
title: str
description: str | None = None
status: TaskStatus
@@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin):
updated_at: datetime
class TaskCountRead(TaskStatusMixin):
workstream_id: uuid.UUID
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
status: TaskStatus
count: int

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
from api.models.technical_debt import TDStatus
@@ -35,7 +35,10 @@ class TDCreate(BaseModel):
severity: str = "medium"
status: TDStatus = TDStatus.open
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
class TDUpdate(BaseModel):
@@ -45,7 +48,10 @@ class TDUpdate(BaseModel):
debt_type: str | None = None
severity: str | None = None
status: TDStatus | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
class TDRead(BaseModel):
@@ -61,7 +67,11 @@ class TDRead(BaseModel):
severity: str
status: TDStatus
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
created_at: datetime
@property
def workstream_id(self) -> uuid.UUID | None:
return self.workplan_id
updated_at: datetime
notes: list[TDNoteRead] = []

View File

@@ -2,14 +2,19 @@ import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, computed_field
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
from api.schemas.compat import OptionalWorkplanIdCompatMixin
class TokenEventCreate(BaseModel):
tokens_in: int
tokens_out: int
task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
repo_id: uuid.UUID | None = None
session_id: str | None = None
model: str | None = None
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
raw_metadata: dict[str, Any] | None = None
class TokenEventRead(BaseModel):
class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tokens_in: int
tokens_out: int
task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
session_id: str | None = None
model: str | None = None
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
tokens_in: int | None = None
tokens_out: int | None = None
task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
repo_id: uuid.UUID | None = None
session_id: str | None = None
note: str | None = None

107
api/schemas/workplan.py Normal file
View File

@@ -0,0 +1,107 @@
import uuid
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from api.schemas.workplan_dependency import WorkplanDepStub
from api.workplan_status import normalize_workplan_status
WorkplanStatus = Literal[
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
]
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
class WorkplanStatusMixin(BaseModel):
@field_validator("status", mode="before", check_fields=False)
@classmethod
def _normalise_status(cls, value):
return normalize_workplan_status(value)
class WorkplanCreate(WorkplanStatusMixin):
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanUpdate(WorkplanStatusMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
topic_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanRead(WorkplanStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
created_at: datetime
updated_at: datetime
class WorkplanWithTaskCounts(WorkplanRead):
tasks_total: int = 0
tasks_wait: int = 0
tasks_todo: int = 0
tasks_progress: int = 0
tasks_done: int = 0
tasks_cancel: int = 0
class WorkplanWithDeps(WorkplanWithTaskCounts):
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkplanDepStub] = []
blocks: list[WorkplanDepStub] = []
blocked_reasons: list[dict] = []

View File

@@ -0,0 +1,63 @@
import uuid
from datetime import datetime
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
class WorkplanDependencyCreate(BaseModel):
to_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("to_workplan_id", "to_workstream_id"),
)
to_task_id: uuid.UUID | None = None
relationship_type: str = "blocks"
description: str | None = None
class WorkplanDependencyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_workplan_id: uuid.UUID
to_workplan_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str
description: str | None = None
created_at: datetime
updated_at: datetime
class WorkplanDepStub(BaseModel):
"""Minimal projection of the other end of a dependency edge."""
dep_id: uuid.UUID
target_type: str = "workplan"
relationship_type: str = "blocks"
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
workplan_slug: str | None = Field(
default=None,
validation_alias=AliasChoices("workplan_slug", "workstream_slug"),
)
workplan_title: str | None = Field(
default=None,
validation_alias=AliasChoices("workplan_title", "workstream_title"),
)
task_id: uuid.UUID | None = None
task_title: str | None = None
description: str | None = None
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID | None:
return self.workplan_id
@computed_field # type: ignore[prop-decorator]
@property
def workstream_slug(self) -> str | None:
return self.workplan_slug
@computed_field # type: ignore[prop-decorator]
@property
def workstream_title(self) -> str | None:
return self.workplan_title

View File

@@ -1,106 +1,41 @@
import uuid
from datetime import date, datetime
from typing import Literal
"""Legacy aliases — prefer ``api.schemas.workplan``."""
from api.schemas.workplan import (
ConcurrencyMode,
ExecutionState,
LaunchMode,
WorkplanCreate,
WorkplanRead,
WorkplanStatus,
WorkplanStatusMixin,
WorkplanUpdate,
WorkplanWithDeps,
WorkplanWithTaskCounts,
)
from pydantic import BaseModel, ConfigDict, field_validator
WorkstreamStatus = WorkplanStatus
WorkstreamStatusMixin = WorkplanStatusMixin
WorkstreamCreate = WorkplanCreate
WorkstreamUpdate = WorkplanUpdate
WorkstreamRead = WorkplanRead
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
WorkstreamWithDeps = WorkplanWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.workplan_status import normalize_workstream_status
WorkstreamStatus = Literal[
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
]
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
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
description: str | None = None
status: WorkstreamStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
repo_goal_id: uuid.UUID | None = None
class WorkstreamUpdate(WorkstreamStatusMixin):
title: str | None = None
description: str | None = None
status: WorkstreamStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkstreamRead(WorkstreamStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkstreamStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
created_at: datetime
updated_at: datetime
class WorkstreamWithTaskCounts(WorkstreamRead):
tasks_total: int = 0
tasks_wait: int = 0
tasks_todo: int = 0
tasks_progress: int = 0
tasks_done: int = 0
tasks_cancel: int = 0
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkstreamDepStub] = []
blocks: list[WorkstreamDepStub] = []
blocked_reasons: list[dict] = []
__all__ = [
"WorkstreamStatus",
"WorkstreamStatusMixin",
"WorkstreamCreate",
"WorkstreamUpdate",
"WorkstreamRead",
"WorkstreamWithTaskCounts",
"WorkstreamWithDeps",
"WorkplanStatus",
"WorkplanStatusMixin",
"WorkplanCreate",
"WorkplanUpdate",
"WorkplanRead",
"WorkplanWithTaskCounts",
"WorkplanWithDeps",
"ExecutionState",
"LaunchMode",
"ConcurrencyMode",
]

View File

@@ -1,36 +1,19 @@
import uuid
from datetime import datetime
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
from api.schemas.workplan_dependency import (
WorkplanDepStub,
WorkplanDependencyCreate,
WorkplanDependencyRead,
)
from pydantic import BaseModel, ConfigDict
WorkstreamDependencyCreate = WorkplanDependencyCreate
WorkstreamDependencyRead = WorkplanDependencyRead
WorkstreamDepStub = WorkplanDepStub
class WorkstreamDependencyCreate(BaseModel):
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str = "blocks"
description: str | None = None
class WorkstreamDependencyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_workstream_id: uuid.UUID
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str
description: str | None = None
created_at: datetime
updated_at: datetime
class WorkstreamDepStub(BaseModel):
"""Minimal projection of the other end of a dependency edge."""
dep_id: uuid.UUID
target_type: str = "workstream"
relationship_type: str = "blocks"
workstream_id: uuid.UUID | None = None
workstream_slug: str | None = None
workstream_title: str | None = None
task_id: uuid.UUID | None = None
task_title: str | None = None
description: str | None = None
__all__ = [
"WorkstreamDependencyCreate",
"WorkstreamDependencyRead",
"WorkstreamDepStub",
"WorkplanDependencyCreate",
"WorkplanDependencyRead",
"WorkplanDepStub",
]

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from api.workplan_status import normalize_workstream_status
from api.workplan_status import normalize_workplan_status
EXECUTION_STATES = {
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
"low": 3,
}
CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"}
CLOSED_WORKPLAN_STATUSES = {"finished", "archived"}
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
@@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False)
return "queued"
def workstream_blockers(
workstream_id: Any,
def workplan_blockers(
workplan_id: Any,
dependency_targets: dict[Any, list[Any]],
workstream_status: dict[Any, str],
workplan_status: dict[Any, str],
workstream_id: Any = None,
) -> list[Any]:
scope_id = workplan_id if workplan_id is not None else workstream_id
blockers = []
for target_id in dependency_targets.get(workstream_id, []):
target_status = normalize_workstream_status(workstream_status.get(target_id))
if target_status not in CLOSED_WORKSTREAM_STATUSES:
for target_id in dependency_targets.get(scope_id, []):
target_status = normalize_workplan_status(workplan_status.get(target_id))
if target_status not in CLOSED_WORKPLAN_STATUSES:
blockers.append(target_id)
return blockers
workstream_blockers = workplan_blockers
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()

View File

@@ -4,14 +4,16 @@ from dataclasses import dataclass
from typing import Any
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
from api.workplan_status import normalize_workstream_status
from api.workplan_status import normalize_workplan_status
TASK_STARTED_STATUS = "progress"
TASK_NOT_STARTED_STATUS = "todo"
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
# Legacy alias
normalize_workstream_status = normalize_workplan_status
@dataclass(frozen=True)
class LifecycleTransitionResult:
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
*,
previous_task_status: Any,
new_task_status: Any,
parent_workstream_status: Any,
parent_workplan_status: Any = None,
parent_workstream_status: Any = None,
) -> bool:
"""Return whether a task start should move its parent to active."""
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
return (
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
and status_value(new_task_status) == TASK_STARTED_STATUS
and normalize_workstream_status(parent_workstream_status)
and normalize_workplan_status(parent_status)
in PARENT_ACTIVATION_STATUSES
)
@@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
def should_activate_parent_for_active_tasks(
*,
parent_workstream_status: Any,
parent_workplan_status: Any = None,
parent_workstream_status: Any = None,
task_statuses: list[Any] | tuple[Any, ...],
) -> bool:
"""Return whether existing task state implies an active parent workstream."""
"""Return whether existing task state implies an active parent workplan."""
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
return (
normalize_workstream_status(parent_workstream_status)
normalize_workplan_status(parent_status)
in PARENT_ACTIVATION_STATUSES
and has_active_task_status(task_statuses)
)
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
*,
previous_task_status: Any,
new_task_status: Any,
parent_workstream: Any,
parent_workplan: Any = None,
parent_workstream: Any = None,
) -> bool:
"""Activate a planning-state parent workstream when real task work starts."""
if parent_workstream is None:
"""Activate a planning-state parent workplan when real task work starts."""
parent = parent_workplan if parent_workplan is not None else parent_workstream
if parent is None:
return False
if not should_activate_parent_for_task_start(
previous_task_status=previous_task_status,
new_task_status=new_task_status,
parent_workstream_status=getattr(parent_workstream, "status", None),
parent_workplan_status=getattr(parent, "status", None),
parent_workstream_status=getattr(parent, "status", None),
):
return False
parent_workstream.status = "active"
parent.status = "active"
return True
def transition_workstream_status(
workstream: Any,
def transition_workplan_status(
workplan: Any,
target_status: Any,
) -> LifecycleTransitionResult:
"""Apply a canonical workstream status transition."""
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
normalised_target = normalize_workstream_status(target_status)
workstream.status = normalised_target
"""Apply a canonical workplan status transition."""
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
normalised_target = normalize_workplan_status(target_status)
workplan.status = normalised_target
return LifecycleTransitionResult(
entity_type="workstream",
entity_type="workplan",
previous_status=previous_status,
target_status=normalised_target,
changed=previous_status != normalised_target,
)
transition_workstream_status = transition_workplan_status
def transition_task_status(
task: Any,
target_status: Any,
*,
parent_workplan: Any = None,
parent_workstream: Any = None,
previous_task_status: Any = None,
status_coercer: Any = None,
) -> LifecycleTransitionResult:
"""Apply a task status transition and activate the parent when work starts."""
parent = parent_workplan if parent_workplan is not None else parent_workstream
previous_status = status_value(
getattr(task, "status", None)
if previous_task_status is None
@@ -109,7 +123,8 @@ def transition_task_status(
parent_activated = activate_parent_for_task_start(
previous_task_status=previous_status,
new_task_status=normalised_target,
parent_workstream=parent_workstream,
parent_workplan=parent,
parent_workstream=parent,
)
return LifecycleTransitionResult(
entity_type="task",
@@ -117,4 +132,4 @@ def transition_task_status(
target_status=normalised_target,
changed=previous_status != normalised_target,
parent_activated=parent_activated,
)
)

View File

@@ -20,7 +20,7 @@ from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.recently_on_scope import (
RecentlyOnScopeFailedDomain,
RecentlyOnScopeHourlyRun,
@@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi
return list(result.scalars().all())
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]:
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]:
result = await session.execute(
select(Workstream)
.where(_in(Workstream.topic_id, topic_ids))
.order_by(Workstream.updated_at.desc(), Workstream.created_at.desc())
select(Workplan)
.where(_in(Workplan.topic_id, topic_ids))
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
)
return list(result.scalars().all())
@@ -356,7 +356,7 @@ async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
result = await session.execute(
select(Task)
.where(_in(Task.workstream_id, workstream_ids))
.where(_in(Task.workplan_id, workstream_ids))
.order_by(Task.updated_at.desc(), Task.created_at.desc())
)
return list(result.scalars().all())
@@ -370,7 +370,7 @@ async def _list_recent_decisions(
) -> list[Decision]:
result = await session.execute(
select(Decision)
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids)))
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids)))
.where(
or_(
_between(Decision.created_at, window),
@@ -397,7 +397,7 @@ async def _list_recent_progress(
.where(
or_(
_in(ProgressEvent.topic_id, topic_ids),
_in(ProgressEvent.workstream_id, workstream_ids),
_in(ProgressEvent.workplan_id, workstream_ids),
_in(ProgressEvent.task_id, task_ids),
_in(ProgressEvent.decision_id, decision_ids),
)
@@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]:
"event_type": event.event_type,
"summary": event.summary,
"author": event.author,
"workstream_id": str(event.workstream_id) if event.workstream_id else None,
"workplan_id": str(event.workplan_id) if event.workplan_id else None,
"workstream_id": str(event.workplan_id) if event.workplan_id else None,
"task_id": str(event.task_id) if event.task_id else None,
"decision_id": str(event.decision_id) if event.decision_id else None,
}
@@ -569,7 +570,7 @@ def _decision_data(decision: Decision) -> dict[str, Any]:
}
def _workstream_data(workstream: Workstream) -> dict[str, Any]:
def _workstream_data(workstream: Workplan) -> dict[str, Any]:
return {
"id": str(workstream.id),
"slug": workstream.slug,
@@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]:
def _task_data(task: Task) -> dict[str, Any]:
return {
"id": str(task.id),
"workstream_id": str(task.workstream_id),
"workstream_id": str(task.workplan_id),
"title": task.title,
"status": _enum_value(task.status),
"priority": _enum_value(task.priority),

View File

@@ -6,7 +6,7 @@ from typing import Any
from api.services.lifecycle import status_value
from api.task_status import CANONICAL_TASK_STATUSES
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
class ReconciliationClass(str, Enum):
@@ -22,11 +22,11 @@ class StateChangeClassification:
follow_up: str
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"}
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
def classify_workstream_status_change(
def classify_workplan_status_change(
*,
current_status: Any,
target_status: Any,
@@ -35,8 +35,8 @@ def classify_workstream_status_change(
tasks_terminal: bool | None = None,
) -> StateChangeClassification:
"""Classify a UI-originated workstream status transition."""
current = normalize_workstream_status(current_status)
target = normalize_workstream_status(target_status)
current = normalize_workplan_status(current_status)
target = normalize_workplan_status(target_status)
if not file_backed:
return StateChangeClassification(
@@ -56,7 +56,7 @@ def classify_workstream_status_change(
"status is unchanged",
"no file update required",
)
if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES:
if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"open lifecycle transition can be represented in workplan frontmatter",
@@ -93,6 +93,9 @@ def classify_workstream_status_change(
)
classify_workstream_status_change = classify_workplan_status_change
def classify_task_status_change(
*,
current_status: Any,

View File

@@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
return None
def find_workplan_for_workstream(
def find_workplan_for_workplan(
repo: ManagedRepo | None,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
) -> WorkplanFileRef | None:
repo_path = resolve_repo_path(repo)
if repo_path is None:
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
continue
for path in sorted(directory.glob("*.md")):
meta = _frontmatter(path)
if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id):
file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id")
if str(file_id or "").strip().strip('"') == str(workplan_id):
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
return None
find_workplan_for_workstream = find_workplan_for_workplan
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
return _task_block_for_task(path, task_id) is not None

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
"proposed",
"ready",
"active",
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
"archived",
)
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
LEGACY_WORKPLAN_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(),
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
*CANONICAL_WORKPLAN_STATUSES,
*LEGACY_WORKPLAN_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")
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
# Legacy aliases (workstream terminology)
CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES
LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES
SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES
OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES
CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES
CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES
PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES
@dataclass(frozen=True)
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
changed_paths: tuple[str, ...] = ()
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
def normalize_workplan_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)
return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value)
def is_canonical_workstream_status(status: Any) -> bool:
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
normalize_workstream_status = normalize_workplan_status
def is_supported_workstream_status(status: Any) -> bool:
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
def is_canonical_workplan_status(status: Any) -> bool:
return _status_value(status) in CANONICAL_WORKPLAN_STATUSES
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
is_canonical_workstream_status = is_canonical_workplan_status
def is_supported_workplan_status(status: Any) -> bool:
return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES
is_supported_workstream_status = is_supported_workplan_status
def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
workstream_has_started = workplan_has_started
def ready_review_status(
repo_dir: str | Path,
reviewed_against_commit: Any,