generated from coulomb/repo-seed
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:
290
api/classification.py
Normal file
290
api/classification.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
70
api/models/workplan.py
Normal 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"
|
||||
)
|
||||
75
api/models/workplan_dependency.py
Normal file
75
api/models/workplan_dependency.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
43
api/schemas/compat.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
107
api/schemas/workplan.py
Normal 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] = []
|
||||
63
api/schemas/workplan_dependency.py
Normal file
63
api/schemas/workplan_dependency.py
Normal 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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user